From 32350f7a0448287d9c89b4c177a9d0e2507a3c2a Mon Sep 17 00:00:00 2001 From: Runzhe <76929114+razerzhang@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:54:36 +0800 Subject: [PATCH 01/18] feat(api): add scheduled cleanup task for specific workflow logs (#31843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ç« æ¶Šć–† Co-authored-by: Claude Opus 4.5 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: hjlarry Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: hj24 --- api/.env.example | 2 + api/configs/feature/__init__.py | 3 + .../api_workflow_run_repository.py | 6 + .../sqlalchemy_api_workflow_run_repository.py | 6 +- .../clean_workflow_runlogs_precise.py | 117 +++++++++++------- ...ear_free_plan_expired_workflow_run_logs.py | 3 + docker/.env.example | 2 + docker/docker-compose.yaml | 1 + 8 files changed, 93 insertions(+), 47 deletions(-) diff --git a/api/.env.example b/api/.env.example index 554b1624ec..38a096da0a 100644 --- a/api/.env.example +++ b/api/.env.example @@ -553,6 +553,8 @@ WORKFLOW_LOG_CLEANUP_ENABLED=false WORKFLOW_LOG_RETENTION_DAYS=30 # Batch size for workflow log cleanup operations (default: 100) WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 +# Comma-separated list of workflow IDs to clean logs for +WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS= # App configuration APP_MAX_EXECUTION_TIME=1200 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 46dad6fc05..3fe9031dff 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1314,6 +1314,9 @@ class WorkflowLogConfig(BaseSettings): WORKFLOW_LOG_CLEANUP_BATCH_SIZE: int = Field( default=100, description="Batch size for workflow run log cleanup operations" ) + WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS: str = Field( + default="", description="Comma-separated list of workflow IDs to clean logs for" + ) class SwaggerUIConfig(BaseSettings): diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py index 17e01a6e18..ffa87b209f 100644 --- a/api/repositories/api_workflow_run_repository.py +++ b/api/repositories/api_workflow_run_repository.py @@ -264,9 +264,15 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): batch_size: int, run_types: Sequence[WorkflowType] | None = None, tenant_ids: Sequence[str] | None = None, + workflow_ids: Sequence[str] | None = None, ) -> Sequence[WorkflowRun]: """ Fetch ended workflow runs in a time window for archival and clean batching. + + Optional filters: + - run_types + - tenant_ids + - workflow_ids """ ... diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index 00cb979e17..7935dfb225 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -386,6 +386,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): batch_size: int, run_types: Sequence[WorkflowType] | None = None, tenant_ids: Sequence[str] | None = None, + workflow_ids: Sequence[str] | None = None, ) -> Sequence[WorkflowRun]: """ Fetch ended workflow runs in a time window for archival and clean batching. @@ -394,7 +395,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): - created_at in [start_from, end_before) - type in run_types (when provided) - status is an ended state - - optional tenant_id filter and cursor (last_seen) for pagination + - optional tenant_id, workflow_id filters and cursor (last_seen) for pagination """ with self._session_maker() as session: stmt = ( @@ -417,6 +418,9 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): if tenant_ids: stmt = stmt.where(WorkflowRun.tenant_id.in_(tenant_ids)) + if workflow_ids: + stmt = stmt.where(WorkflowRun.workflow_id.in_(workflow_ids)) + if last_seen: stmt = stmt.where( or_( diff --git a/api/schedule/clean_workflow_runlogs_precise.py b/api/schedule/clean_workflow_runlogs_precise.py index db4198720d..ebb8d52924 100644 --- a/api/schedule/clean_workflow_runlogs_precise.py +++ b/api/schedule/clean_workflow_runlogs_precise.py @@ -4,7 +4,6 @@ import time from collections.abc import Sequence import click -from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker import app @@ -13,6 +12,7 @@ from extensions.ext_database import db from models.model import ( AppAnnotationHitHistory, Conversation, + DatasetRetrieverResource, Message, MessageAgentThought, MessageAnnotation, @@ -20,7 +20,10 @@ from models.model import ( MessageFeedback, MessageFile, ) -from models.workflow import ConversationVariable, WorkflowAppLog, WorkflowNodeExecutionModel, WorkflowRun +from models.web import SavedMessage +from models.workflow import ConversationVariable, WorkflowRun +from repositories.factory import DifyAPIRepositoryFactory +from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository logger = logging.getLogger(__name__) @@ -29,8 +32,15 @@ MAX_RETRIES = 3 BATCH_SIZE = dify_config.WORKFLOW_LOG_CLEANUP_BATCH_SIZE -@app.celery.task(queue="dataset") -def clean_workflow_runlogs_precise(): +def _get_specific_workflow_ids() -> list[str]: + workflow_ids_str = dify_config.WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS.strip() + if not workflow_ids_str: + return [] + return [wid.strip() for wid in workflow_ids_str.split(",") if wid.strip()] + + +@app.celery.task(queue="retention") +def clean_workflow_runlogs_precise() -> None: """Clean expired workflow run logs with retry mechanism and complete message cascade""" click.echo(click.style("Start clean workflow run logs (precise mode with complete cascade).", fg="green")) @@ -39,48 +49,48 @@ def clean_workflow_runlogs_precise(): retention_days = dify_config.WORKFLOW_LOG_RETENTION_DAYS cutoff_date = datetime.datetime.now() - datetime.timedelta(days=retention_days) session_factory = sessionmaker(db.engine, expire_on_commit=False) + workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory) + workflow_ids = _get_specific_workflow_ids() + workflow_ids_filter = workflow_ids or None try: - with session_factory.begin() as session: - total_workflow_runs = session.query(WorkflowRun).where(WorkflowRun.created_at < cutoff_date).count() - if total_workflow_runs == 0: - logger.info("No expired workflow run logs found") - return - logger.info("Found %s expired workflow run logs to clean", total_workflow_runs) - total_deleted = 0 failed_batches = 0 batch_count = 0 + last_seen: tuple[datetime.datetime, str] | None = None while True: + run_rows = workflow_run_repo.get_runs_batch_by_time_range( + start_from=None, + end_before=cutoff_date, + last_seen=last_seen, + batch_size=BATCH_SIZE, + workflow_ids=workflow_ids_filter, + ) + + if not run_rows: + if batch_count == 0: + logger.info("No expired workflow run logs found") + break + + last_seen = (run_rows[-1].created_at, run_rows[-1].id) + batch_count += 1 with session_factory.begin() as session: - workflow_run_ids = session.scalars( - select(WorkflowRun.id) - .where(WorkflowRun.created_at < cutoff_date) - .order_by(WorkflowRun.created_at, WorkflowRun.id) - .limit(BATCH_SIZE) - ).all() + success = _delete_batch(session, workflow_run_repo, run_rows, failed_batches) - if not workflow_run_ids: + if success: + total_deleted += len(run_rows) + failed_batches = 0 + else: + failed_batches += 1 + if failed_batches >= MAX_RETRIES: + logger.error("Failed to delete batch after %s retries, aborting cleanup for today", MAX_RETRIES) break - - batch_count += 1 - - success = _delete_batch(session, workflow_run_ids, failed_batches) - - if success: - total_deleted += len(workflow_run_ids) - failed_batches = 0 else: - failed_batches += 1 - if failed_batches >= MAX_RETRIES: - logger.error("Failed to delete batch after %s retries, aborting cleanup for today", MAX_RETRIES) - break - else: - # Calculate incremental delay times: 5, 10, 15 minutes - retry_delay_minutes = failed_batches * 5 - logger.warning("Batch deletion failed, retrying in %s minutes...", retry_delay_minutes) - time.sleep(retry_delay_minutes * 60) - continue + # Calculate incremental delay times: 5, 10, 15 minutes + retry_delay_minutes = failed_batches * 5 + logger.warning("Batch deletion failed, retrying in %s minutes...", retry_delay_minutes) + time.sleep(retry_delay_minutes * 60) + continue logger.info("Cleanup completed: %s expired workflow run logs deleted", total_deleted) @@ -93,10 +103,16 @@ def clean_workflow_runlogs_precise(): click.echo(click.style(f"Cleaned workflow run logs from db success latency: {execution_time:.2f}s", fg="green")) -def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_count: int) -> bool: +def _delete_batch( + session: Session, + workflow_run_repo, + workflow_runs: Sequence[WorkflowRun], + attempt_count: int, +) -> bool: """Delete a single batch of workflow runs and all related data within a nested transaction.""" try: with session.begin_nested(): + workflow_run_ids = [run.id for run in workflow_runs] message_data = ( session.query(Message.id, Message.conversation_id) .where(Message.workflow_run_id.in_(workflow_run_ids)) @@ -107,11 +123,13 @@ def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_cou if message_id_list: message_related_models = [ AppAnnotationHitHistory, + DatasetRetrieverResource, MessageAgentThought, MessageChain, MessageFile, MessageAnnotation, MessageFeedback, + SavedMessage, ] for model in message_related_models: session.query(model).where(model.message_id.in_(message_id_list)).delete(synchronize_session=False) # type: ignore @@ -122,14 +140,6 @@ def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_cou synchronize_session=False ) - session.query(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(workflow_run_ids)).delete( - synchronize_session=False - ) - - session.query(WorkflowNodeExecutionModel).where( - WorkflowNodeExecutionModel.workflow_run_id.in_(workflow_run_ids) - ).delete(synchronize_session=False) - if conversation_id_list: session.query(ConversationVariable).where( ConversationVariable.conversation_id.in_(conversation_id_list) @@ -139,7 +149,22 @@ def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_cou synchronize_session=False ) - session.query(WorkflowRun).where(WorkflowRun.id.in_(workflow_run_ids)).delete(synchronize_session=False) + def _delete_node_executions(active_session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]: + run_ids = [run.id for run in runs] + repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( + session_maker=sessionmaker(bind=active_session.get_bind(), expire_on_commit=False) + ) + return repo.delete_by_runs(active_session, run_ids) + + def _delete_trigger_logs(active_session: Session, run_ids: Sequence[str]) -> int: + trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(active_session) + return trigger_repo.delete_by_run_ids(run_ids) + + workflow_run_repo.delete_runs_with_related( + workflow_runs, + delete_node_executions=_delete_node_executions, + delete_trigger_logs=_delete_trigger_logs, + ) return True diff --git a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py index 8c80e2b4ad..50826d6798 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py @@ -62,6 +62,9 @@ class FakeRepo: end_before: datetime.datetime, last_seen: tuple[datetime.datetime, str] | None, batch_size: int, + run_types=None, + tenant_ids=None, + workflow_ids=None, ) -> list[FakeRun]: if self.call_idx >= len(self.batches): return [] diff --git a/docker/.env.example b/docker/.env.example index 4a141e37d4..3d0009711d 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1073,6 +1073,8 @@ WORKFLOW_LOG_CLEANUP_ENABLED=false WORKFLOW_LOG_RETENTION_DAYS=30 # Batch size for workflow log cleanup operations (default: 100) WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 +# Comma-separated list of workflow IDs to clean logs for +WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS= # Aliyun SLS Logstore Configuration # Aliyun Access Key ID diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 6340dd290e..003ecf8497 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -470,6 +470,7 @@ x-shared-env: &shared-api-worker-env WORKFLOW_LOG_CLEANUP_ENABLED: ${WORKFLOW_LOG_CLEANUP_ENABLED:-false} WORKFLOW_LOG_RETENTION_DAYS: ${WORKFLOW_LOG_RETENTION_DAYS:-30} WORKFLOW_LOG_CLEANUP_BATCH_SIZE: ${WORKFLOW_LOG_CLEANUP_BATCH_SIZE:-100} + WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS: ${WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS:-} ALIYUN_SLS_ACCESS_KEY_ID: ${ALIYUN_SLS_ACCESS_KEY_ID:-} ALIYUN_SLS_ACCESS_KEY_SECRET: ${ALIYUN_SLS_ACCESS_KEY_SECRET:-} ALIYUN_SLS_ENDPOINT: ${ALIYUN_SLS_ENDPOINT:-} From f953331f912c44b143711c8b3220d2136ee98d07 Mon Sep 17 00:00:00 2001 From: Saumya Talwani <68903741+saumyatalwani@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:21:18 +0530 Subject: [PATCH 02/18] test: add unit tests for some base components (#32201) --- .../base/answer-icon/index.spec.tsx | 34 ++ .../components/base/copy-icon/index.spec.tsx | 54 +++ .../base/corner-label/index.spec.tsx | 16 + .../base/drawer-plus/index.spec.tsx | 447 ++++++++++++++++++ .../components/base/dropdown/index.spec.tsx | 225 +++++++++ web/app/components/base/effect/index.spec.tsx | 9 + .../base/encrypted-bottom/index.spec.tsx | 15 + .../components/base/file-icon/index.spec.tsx | 28 ++ .../base/node-status/index.spec.tsx | 61 +++ .../base/notion-icon/index.spec.tsx | 49 ++ .../base/premium-badge/index.spec.tsx | 46 ++ 11 files changed, 984 insertions(+) create mode 100644 web/app/components/base/answer-icon/index.spec.tsx create mode 100644 web/app/components/base/copy-icon/index.spec.tsx create mode 100644 web/app/components/base/corner-label/index.spec.tsx create mode 100644 web/app/components/base/drawer-plus/index.spec.tsx create mode 100644 web/app/components/base/dropdown/index.spec.tsx create mode 100644 web/app/components/base/effect/index.spec.tsx create mode 100644 web/app/components/base/encrypted-bottom/index.spec.tsx create mode 100644 web/app/components/base/file-icon/index.spec.tsx create mode 100644 web/app/components/base/node-status/index.spec.tsx create mode 100644 web/app/components/base/notion-icon/index.spec.tsx create mode 100644 web/app/components/base/premium-badge/index.spec.tsx diff --git a/web/app/components/base/answer-icon/index.spec.tsx b/web/app/components/base/answer-icon/index.spec.tsx new file mode 100644 index 0000000000..72573fca5b --- /dev/null +++ b/web/app/components/base/answer-icon/index.spec.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react' +import AnswerIcon from '.' + +describe('AnswerIcon', () => { + it('renders default emoji when no icon or image is provided', () => { + const { container } = render() + const emojiElement = container.querySelector('em-emoji') + expect(emojiElement).toBeInTheDocument() + expect(emojiElement).toHaveAttribute('id', 'đŸ€–') + }) + + it('renders with custom emoji when icon is provided', () => { + const { container } = render() + const emojiElement = container.querySelector('em-emoji') + expect(emojiElement).toBeInTheDocument() + expect(emojiElement).toHaveAttribute('id', 'smile') + }) + it('renders image when iconType is image and imageUrl is provided', () => { + render() + const imgElement = screen.getByAltText('answer icon') + expect(imgElement).toBeInTheDocument() + expect(imgElement).toHaveAttribute('src', 'test-image.jpg') + }) + + it('applies custom background color', () => { + const { container } = render() + expect(container.firstChild).toHaveStyle('background: #FF5500') + }) + + it('uses default background color when no background is provided for non-image icons', () => { + const { container } = render() + expect(container.firstChild).toHaveStyle('background: #D5F5F6') + }) +}) diff --git a/web/app/components/base/copy-icon/index.spec.tsx b/web/app/components/base/copy-icon/index.spec.tsx new file mode 100644 index 0000000000..b4cf192174 --- /dev/null +++ b/web/app/components/base/copy-icon/index.spec.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render } from '@testing-library/react' +import CopyIcon from '.' + +const copy = vi.fn() +const reset = vi.fn() +let copied = false + +vi.mock('foxact/use-clipboard', () => ({ + useClipboard: () => ({ + copy, + reset, + copied, + }), +})) + +describe('copy icon component', () => { + beforeEach(() => { + vi.resetAllMocks() + copied = false + }) + + it('renders normally', () => { + const { container } = render() + expect(container.querySelector('svg')).not.toBeNull() + }) + + it('shows copy icon initially', () => { + const { container } = render() + const icon = container.querySelector('[data-icon="Copy"]') + expect(icon).toBeInTheDocument() + }) + + it('shows copy check icon when copied', () => { + copied = true + const { container } = render() + const icon = container.querySelector('[data-icon="CopyCheck"]') + expect(icon).toBeInTheDocument() + }) + + it('handles copy when clicked', () => { + const { container } = render() + const icon = container.querySelector('[data-icon="Copy"]') + fireEvent.click(icon as Element) + expect(copy).toBeCalledTimes(1) + }) + + it('resets on mouse leave', () => { + const { container } = render() + const icon = container.querySelector('[data-icon="Copy"]') + const div = icon?.parentElement as HTMLElement + fireEvent.mouseLeave(div) + expect(reset).toBeCalledTimes(1) + }) +}) diff --git a/web/app/components/base/corner-label/index.spec.tsx b/web/app/components/base/corner-label/index.spec.tsx new file mode 100644 index 0000000000..479eaeff0d --- /dev/null +++ b/web/app/components/base/corner-label/index.spec.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react' +import CornerLabel from '.' + +describe('CornerLabel', () => { + it('renders the label correctly', () => { + render() + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('applies custom class names', () => { + const { container } = render() + expect(container.querySelector('.custom-class')).toBeInTheDocument() + expect(container.querySelector('.custom-label-class')).toBeInTheDocument() + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/drawer-plus/index.spec.tsx b/web/app/components/base/drawer-plus/index.spec.tsx new file mode 100644 index 0000000000..e2d5c88df8 --- /dev/null +++ b/web/app/components/base/drawer-plus/index.spec.tsx @@ -0,0 +1,447 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import DrawerPlus from '.' + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => 'desktop', + MediaType: { mobile: 'mobile', desktop: 'desktop', tablet: 'tablet' }, +})) + +describe('DrawerPlus', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should not render when isShow is false', () => { + render( + {}} + title="Test Drawer" + body={
Content
} + />, + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('should render when isShow is true', () => { + const bodyContent =
Body Content
+ render( + {}} + title="Test Drawer" + body={bodyContent} + />, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Test Drawer')).toBeInTheDocument() + expect(screen.getByText('Body Content')).toBeInTheDocument() + }) + + it('should render footer when provided', () => { + const footerContent =
Footer Content
+ render( + {}} + title="Test Drawer" + body={
Body
} + foot={footerContent} + />, + ) + + expect(screen.getByText('Footer Content')).toBeInTheDocument() + }) + + it('should render JSX element as title', () => { + const titleElement =

Custom Title

+ render( + {}} + title={titleElement} + body={
Body
} + />, + ) + + expect(screen.getByTestId('custom-title')).toBeInTheDocument() + }) + + it('should render titleDescription when provided', () => { + render( + {}} + title="Test Drawer" + titleDescription="Description text" + body={
Body
} + />, + ) + + expect(screen.getByText('Description text')).toBeInTheDocument() + }) + + it('should not render titleDescription when not provided', () => { + render( + {}} + title="Test Drawer" + body={
Body
} + />, + ) + + expect(screen.queryByText(/Description/)).not.toBeInTheDocument() + }) + + it('should render JSX element as titleDescription', () => { + const descElement = Custom Description + render( + {}} + title="Test" + titleDescription={descElement} + body={
Body
} + />, + ) + + expect(screen.getByTestId('custom-desc')).toBeInTheDocument() + }) + }) + + describe('Props - Display Options', () => { + it('should apply default maxWidthClassName', () => { + render( + {}} + title="Test" + body={
Body
} + />, + ) + const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg') + const outerPanel = innerPanel?.parentElement + expect(outerPanel?.className).toContain('!max-w-[640px]') + }) + + it('should apply custom maxWidthClassName', () => { + render( + {}} + title="Test" + body={
Body
} + maxWidthClassName="!max-w-[800px]" + />, + ) + + const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg') + const outerPanel = innerPanel?.parentElement + expect(outerPanel?.className).toContain('!max-w-[800px]') + }) + + it('should apply custom panelClassName', () => { + render( + {}} + title="Test" + body={
Body
} + panelClassName="custom-panel" + />, + ) + + const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg') + const outerPanel = innerPanel?.parentElement + expect(outerPanel?.className).toContain('custom-panel') + }) + + it('should apply custom dialogClassName', () => { + render( + {}} + title="Test" + body={
Body
} + dialogClassName="custom-dialog" + />, + ) + + const dialog = screen.getByRole('dialog') + expect(dialog.className).toContain('custom-dialog') + }) + + it('should apply custom contentClassName', () => { + render( + {}} + title="Test" + body={
Body
} + contentClassName="custom-content" + />, + ) + const title = screen.getByText('Test') + const header = title.closest('.shrink-0.border-b.border-divider-subtle') + const content = header?.parentElement + expect(content?.className).toContain('custom-content') + }) + + it('should apply custom headerClassName', () => { + render( + {}} + title="Test" + body={
Body
} + headerClassName="custom-header" + />, + ) + + const title = screen.getByText('Test') + const header = title.closest('.shrink-0.border-b.border-divider-subtle') + expect(header?.className).toContain('custom-header') + }) + + it('should apply custom height', () => { + render( + {}} + title="Test" + body={
Body
} + height="500px" + />, + ) + + const title = screen.getByText('Test') + const header = title.closest('.shrink-0.border-b.border-divider-subtle') + const content = header?.parentElement + expect(content?.getAttribute('style')).toContain('height: 500px') + }) + + it('should use default height', () => { + render( + {}} + title="Test" + body={
Body
} + />, + ) + + const title = screen.getByText('Test') + const header = title.closest('.shrink-0.border-b.border-divider-subtle') + const content = header?.parentElement + expect(content?.getAttribute('style')).toContain('calc(100vh - 72px)') + }) + }) + + describe('Event Handlers', () => { + it('should call onHide when close button is clicked', () => { + const handleHide = vi.fn() + render( + Body} + />, + ) + + const title = screen.getByText('Test') + const headerRight = title.nextElementSibling // .flex items-center + const closeDiv = headerRight?.querySelector('.cursor-pointer') as HTMLElement + + fireEvent.click(closeDiv) + expect(handleHide).toHaveBeenCalledTimes(1) + }) + }) + + describe('Complex Content', () => { + it('should render complex JSX elements in body', () => { + const complexBody = ( +
+

Header

+

Paragraph

+ +
+ ) + + render( + {}} + title="Test" + body={complexBody} + />, + ) + + expect(screen.getByText('Header')).toBeInTheDocument() + expect(screen.getByText('Paragraph')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Action Button' })).toBeInTheDocument() + }) + + it('should render complex footer', () => { + const complexFooter = ( +
+ + +
+ ) + + render( + {}} + title="Test" + body={
Body
} + foot={complexFooter} + />, + ) + + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty title', () => { + render( + {}} + title="" + body={
Body
} + />, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle undefined titleDescription', () => { + render( + {}} + title="Test" + titleDescription={undefined} + body={
Body
} + />, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle rapid isShow toggle', () => { + const { rerender } = render( + {}} + title="Test" + body={
Body
} + />, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + + rerender( + {}} + title="Test" + body={
Body
} + />, + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + rerender( + {}} + title="Test" + body={
Body
} + />, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle special characters in title', () => { + const specialTitle = 'Test <> & " \' | Drawer' + render( + {}} + title={specialTitle} + body={
Body
} + />, + ) + + expect(screen.getByText(specialTitle)).toBeInTheDocument() + }) + + it('should handle empty body content', () => { + render( + {}} + title="Test" + body={
} + />, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should apply both custom maxWidth and panel classNames', () => { + render( + {}} + title="Test" + body={
Body
} + maxWidthClassName="!max-w-[500px]" + panelClassName="custom-style" + />, + ) + + const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg') + const outerPanel = innerPanel?.parentElement + expect(outerPanel?.className).toContain('!max-w-[500px]') + expect(outerPanel?.className).toContain('custom-style') + }) + }) + + describe('Memoization', () => { + it('should be memoized and not re-render on parent changes', () => { + const { rerender } = render( + {}} + title="Test" + body={
Body
} + />, + ) + + const dialog = screen.getByRole('dialog') + + rerender( + {}} + title="Test" + body={
Body
} + />, + ) + + expect(dialog).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/dropdown/index.spec.tsx b/web/app/components/base/dropdown/index.spec.tsx new file mode 100644 index 0000000000..7d61b332d4 --- /dev/null +++ b/web/app/components/base/dropdown/index.spec.tsx @@ -0,0 +1,225 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import Dropdown from './index' + +describe('Dropdown Component', () => { + const mockItems = [ + { value: 'option1', text: 'Option 1' }, + { value: 'option2', text: 'Option 2' }, + ] + const mockSecondItems = [ + { value: 'option3', text: 'Option 3' }, + ] + const onSelect = vi.fn() + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders default trigger properly', () => { + const { container } = render( + , + ) + const trigger = container.querySelector('button') + expect(trigger).toBeInTheDocument() + }) + + it('renders custom trigger when provided', () => { + render( + } + />, + ) + const trigger = screen.getByTestId('custom-trigger') + expect(trigger).toBeInTheDocument() + expect(trigger).toHaveTextContent('Closed') + }) + + it('opens dropdown menu on trigger click and shows items', async () => { + render( + , + ) + const trigger = screen.getByRole('button') + + await act(async () => { + fireEvent.click(trigger) + }) + + // Dropdown items are rendered in a portal (document.body) + expect(screen.getByText('Option 1')).toBeInTheDocument() + expect(screen.getByText('Option 2')).toBeInTheDocument() + }) + + it('calls onSelect and closes dropdown when an item is clicked', async () => { + render( + , + ) + const trigger = screen.getByRole('button') + + await act(async () => { + fireEvent.click(trigger) + }) + + const option1 = screen.getByText('Option 1') + await act(async () => { + fireEvent.click(option1) + }) + + expect(onSelect).toHaveBeenCalledWith(mockItems[0]) + expect(screen.queryByText('Option 1')).not.toBeInTheDocument() + }) + + it('calls onSelect and closes dropdown when a second item is clicked', async () => { + render( + , + ) + + await act(async () => { + fireEvent.click(screen.getByRole('button')) + }) + + const option3 = screen.getByText('Option 3') + await act(async () => { + fireEvent.click(option3) + }) + expect(onSelect).toHaveBeenCalledWith(mockSecondItems[0]) + expect(screen.queryByText('Option 3')).not.toBeInTheDocument() + }) + + it('renders second items and divider when provided', async () => { + render( + , + ) + const trigger = screen.getByRole('button') + + await act(async () => { + fireEvent.click(trigger) + }) + + expect(screen.getByText('Option 1')).toBeInTheDocument() + expect(screen.getByText('Option 3')).toBeInTheDocument() + + // Check for divider (h-px bg-divider-regular) + const divider = document.body.querySelector('.bg-divider-regular.h-px') + expect(divider).toBeInTheDocument() + }) + + it('applies custom classNames', async () => { + const popupClass = 'custom-popup' + const itemClass = 'custom-item' + const secondItemClass = 'custom-second-item' + + render( + , + ) + + await act(async () => { + fireEvent.click(screen.getByRole('button')) + }) + + const popup = document.body.querySelector(`.${popupClass}`) + expect(popup).toBeInTheDocument() + + const items = screen.getAllByText('Option 1') + expect(items[0]).toHaveClass(itemClass) + + const secondItems = screen.getAllByText('Option 3') + expect(secondItems[0]).toHaveClass(secondItemClass) + }) + + it('applies open class to trigger when menu is open', async () => { + render() + const trigger = screen.getByRole('button') + await act(async () => { + fireEvent.click(trigger) + }) + expect(trigger).toHaveClass('bg-divider-regular') + }) + + it('handles JSX elements as item text', async () => { + const itemsWithJSX = [ + { value: 'jsx', text: JSX Content }, + ] + render( + , + ) + + await act(async () => { + fireEvent.click(screen.getByRole('button')) + }) + + expect(screen.getByTestId('jsx-item')).toBeInTheDocument() + expect(screen.getByText('JSX Content')).toBeInTheDocument() + }) + + it('does not render items section if items list is empty', async () => { + render( + , + ) + + await act(async () => { + fireEvent.click(screen.getByRole('button')) + }) + + const p1Divs = document.body.querySelectorAll('.p-1') + expect(p1Divs.length).toBe(1) + expect(screen.queryByText('Option 1')).not.toBeInTheDocument() + expect(screen.getByText('Option 3')).toBeInTheDocument() + }) + + it('does not render divider if only one section is provided', async () => { + const { rerender } = render( + , + ) + await act(async () => { + fireEvent.click(screen.getByRole('button')) + }) + expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument() + + await act(async () => { + rerender( + , + ) + }) + expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument() + }) + + it('renders nothing if both item lists are empty', async () => { + render() + await act(async () => { + fireEvent.click(screen.getByRole('button')) + }) + const popup = document.body.querySelector('.bg-components-panel-bg') + expect(popup?.children.length).toBe(0) + }) + + it('passes triggerProps to ActionButton and applies custom className', () => { + render( + , + ) + const trigger = screen.getByLabelText('dropdown-trigger') + expect(trigger).toBeDisabled() + expect(trigger).toHaveClass('custom-trigger-class') + }) +}) diff --git a/web/app/components/base/effect/index.spec.tsx b/web/app/components/base/effect/index.spec.tsx new file mode 100644 index 0000000000..38410f6987 --- /dev/null +++ b/web/app/components/base/effect/index.spec.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react' +import Effect from '.' + +describe('Effect', () => { + it('applies custom class names', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom-class') + }) +}) diff --git a/web/app/components/base/encrypted-bottom/index.spec.tsx b/web/app/components/base/encrypted-bottom/index.spec.tsx new file mode 100644 index 0000000000..aeeb546fe9 --- /dev/null +++ b/web/app/components/base/encrypted-bottom/index.spec.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@testing-library/react' +import { EncryptedBottom } from '.' + +describe('EncryptedBottom', () => { + it('applies custom class names', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('passes keys', async () => { + render() + expect(await screen.findByText(/provider.encrypted.front/i)).toBeInTheDocument() + expect(await screen.findByText(/provider.encrypted.back/i)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/file-icon/index.spec.tsx b/web/app/components/base/file-icon/index.spec.tsx new file mode 100644 index 0000000000..526a889f34 --- /dev/null +++ b/web/app/components/base/file-icon/index.spec.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react' +import FileIcon from '.' + +describe('File icon component', () => { + const testCases = [ + { type: 'csv', icon: 'Csv' }, + { type: 'doc', icon: 'Doc' }, + { type: 'docx', icon: 'Docx' }, + { type: 'htm', icon: 'Html' }, + { type: 'html', icon: 'Html' }, + { type: 'md', icon: 'Md' }, + { type: 'mdx', icon: 'Md' }, + { type: 'markdown', icon: 'Md' }, + { type: 'pdf', icon: 'Pdf' }, + { type: 'xls', icon: 'Xlsx' }, + { type: 'xlsx', icon: 'Xlsx' }, + { type: 'notion', icon: 'Notion' }, + { type: 'something-else', icon: 'Unknown' }, + { type: 'txt', icon: 'Txt' }, + { type: 'json', icon: 'Json' }, + ] + + it.each(testCases)('renders $icon icon for type $type', ({ type, icon }) => { + const { container } = render() + const iconElement = container.querySelector(`[data-icon="${icon}"]`) + expect(iconElement).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/node-status/index.spec.tsx b/web/app/components/base/node-status/index.spec.tsx new file mode 100644 index 0000000000..566a537653 --- /dev/null +++ b/web/app/components/base/node-status/index.spec.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react' +import NodeStatus, { NodeStatusEnum } from '.' + +describe('NodeStatus', () => { + it('renders with default status (warning) and default message', () => { + const { container } = render() + + expect(screen.getByText('Warning')).toBeInTheDocument() + // Default warning class + expect(container.firstChild).toHaveClass('bg-state-warning-hover') + expect(container.firstChild).toHaveClass('text-text-warning') + }) + + it('renders with error status and default message', () => { + const { container } = render() + + expect(screen.getByText('Error')).toBeInTheDocument() + expect(container.firstChild).toHaveClass('bg-state-destructive-hover') + expect(container.firstChild).toHaveClass('text-text-destructive') + }) + + it('renders with custom message', () => { + render() + expect(screen.getByText('Custom Message')).toBeInTheDocument() + }) + + it('renders children correctly', () => { + render( + + Child Element + , + ) + expect(screen.getByTestId('child')).toBeInTheDocument() + expect(screen.getByText('Child Element')).toBeInTheDocument() + }) + + it('applies custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom-test-class') + }) + + it('applies styleCss correctly', () => { + const { container } = render() + expect(container.firstChild).toHaveStyle({ color: 'rgb(255, 0, 0)' }) + }) + + it('applies iconClassName to the icon', () => { + const { container } = render() + // The icon is the first child of the div + const icon = container.querySelector('.custom-icon-class') + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass('h-3.5') + expect(icon).toHaveClass('w-3.5') + }) + + it('passes additional HTML attributes to the container', () => { + render() + const container = screen.getByTestId('node-status-container') + expect(container).toHaveAttribute('id', 'my-id') + }) +}) diff --git a/web/app/components/base/notion-icon/index.spec.tsx b/web/app/components/base/notion-icon/index.spec.tsx new file mode 100644 index 0000000000..582beab054 --- /dev/null +++ b/web/app/components/base/notion-icon/index.spec.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import NotionIcon from '.' + +describe('Notion Icon', () => { + it('applies custom class names', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('renders image on http url', () => { + render() + expect(screen.getByAltText('workspace icon')).toHaveAttribute('src', 'http://example.com/image.png') + }) + + it('renders image on https url', () => { + render() + expect(screen.getByAltText('workspace icon')).toHaveAttribute('src', 'https://example.com/image.png') + }) + + it('renders div on non-http url', () => { + render() + expect(screen.getByText('example.com/image.png')).toBeInTheDocument() + }) + + it('renders name when no url is provided', () => { + render() + expect(screen.getByText('T')).toBeInTheDocument() + }) + + it('renders image on type url for page', () => { + render() + expect(screen.getByAltText('page icon')).toHaveAttribute('src', 'https://example.com/image.png') + }) + + it('renders blank image on type url if no url is passed for page', () => { + render() + expect(screen.getByAltText('page icon')).not.toHaveAttribute('src') + }) + + it('renders emoji on type emoji for page', () => { + render() + expect(screen.getByText('🚀')).toBeInTheDocument() + }) + + it('renders icon on url for page', () => { + const { container } = render() + expect(container.querySelector('svg')).not.toBeNull() + }) +}) diff --git a/web/app/components/base/premium-badge/index.spec.tsx b/web/app/components/base/premium-badge/index.spec.tsx new file mode 100644 index 0000000000..a589aef828 --- /dev/null +++ b/web/app/components/base/premium-badge/index.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import PremiumBadge from './index' + +describe('PremiumBadge', () => { + it('renders with default props', () => { + render(Premium) + const badge = screen.getByText('Premium') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('premium-badge-m') + expect(badge).toHaveClass('premium-badge-blue') + }) + + it('renders with custom size and color', () => { + render( + + Premium + , + ) + const badge = screen.getByText('Premium') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('premium-badge-s') + expect(badge).toHaveClass('premium-badge-indigo') + }) + + it('applies allowHover class when allowHover is true', () => { + render( + + Premium + , + ) + const badge = screen.getByText('Premium') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('allowHover') + }) + + it('applies custom styles', () => { + render( + + Premium + , + ) + const badge = screen.getByText('Premium') + expect(badge).toBeInTheDocument() + expect(badge).toHaveStyle('background-color: rgb(255, 0, 0)') // Note: React converts 'red' to 'rgb(255, 0, 0)' + }) +}) From 10f85074e8fdc6564bf27cf9c74406930c198c21 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 12 Feb 2026 10:00:32 +0800 Subject: [PATCH 03/18] test: add comprehensive unit and integration tests for dataset module (#32187) Co-authored-by: CodingOnStar Co-authored-by: Cursor --- .../datasets/create-dataset-flow.test.tsx | 301 ++ .../datasets/dataset-settings-flow.test.tsx | 451 +++ .../datasets/document-management.test.tsx | 335 ++ .../datasets/external-knowledge-base.test.tsx | 215 ++ .../datasets/hit-testing-flow.test.tsx | 404 +++ .../metadata-management-flow.test.tsx | 337 ++ .../pipeline-datasource-flow.test.tsx | 477 +++ web/__tests__/datasets/segment-crud.test.tsx | 301 ++ .../base/chat/embedded-chatbot/hooks.spec.tsx | 6 +- .../datasets/__tests__/chunk.spec.tsx | 309 ++ .../datasets/{ => __tests__}/loading.spec.tsx | 2 +- .../no-linked-apps-panel.spec.tsx | 15 +- .../api/{ => __tests__}/index.spec.tsx | 2 +- web/app/components/datasets/chunk.spec.tsx | 111 - .../check-rerank-model.spec.ts | 2 +- .../chunking-mode-label.spec.tsx | 2 +- .../{ => __tests__}/credential-icon.spec.tsx | 2 +- .../document-file-icon.spec.tsx | 2 +- .../__tests__/document-list.spec.tsx | 49 + .../{ => __tests__}/index.spec.tsx | 35 +- .../preview-document-picker.spec.tsx | 69 +- .../auto-disabled-document.spec.tsx | 4 +- .../{ => __tests__}/index-failed.spec.tsx | 3 +- .../status-with-action.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 7 +- .../image-list/{ => __tests__}/index.spec.tsx | 7 +- .../image-list/{ => __tests__}/more.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 6 +- .../{ => __tests__}/store.spec.tsx | 4 +- .../{ => __tests__}/utils.spec.ts | 6 +- .../hooks/{ => __tests__}/use-upload.spec.tsx | 7 +- .../{ => __tests__}/image-input.spec.tsx | 5 +- .../{ => __tests__}/image-item.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 5 +- .../{ => __tests__}/image-input.spec.tsx | 7 +- .../{ => __tests__}/image-item.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 5 +- .../{ => __tests__}/index.spec.tsx | 6 +- .../{ => __tests__}/index.spec.tsx | 8 +- .../{ => __tests__}/index.spec.tsx | 36 +- .../{ => __tests__}/footer.spec.tsx | 29 +- .../{ => __tests__}/header.spec.tsx | 13 +- .../{ => __tests__}/index.spec.tsx | 20 +- .../dsl-confirm-modal.spec.tsx | 17 +- .../{ => __tests__}/header.spec.tsx | 16 +- .../{ => __tests__}/index.spec.tsx | 61 +- .../{ => __tests__}/uploader.spec.tsx | 24 +- .../{ => __tests__}/use-dsl-import.spec.tsx | 6 +- .../tab/{ => __tests__}/index.spec.tsx | 15 +- .../tab/{ => __tests__}/item.spec.tsx | 18 +- .../built-in-pipeline-list.spec.tsx | 26 +- .../list/{ => __tests__}/create-card.spec.tsx | 19 +- .../{ => __tests__}/customized-list.spec.tsx | 17 +- .../list/{ => __tests__}/index.spec.tsx | 17 +- .../{ => __tests__}/actions.spec.tsx | 20 +- .../{ => __tests__}/content.spec.tsx | 24 +- .../edit-pipeline-info.spec.tsx | 35 +- .../{ => __tests__}/index.spec.tsx | 40 +- .../{ => __tests__}/operations.spec.tsx | 16 +- .../chunk-structure-card.spec.tsx | 21 +- .../details/{ => __tests__}/hooks.spec.tsx | 16 +- .../details/{ => __tests__}/index.spec.tsx | 29 +- .../create/{ => __tests__}/index.spec.tsx | 288 +- .../{ => __tests__}/index.spec.tsx | 239 +- .../__tests__/indexing-progress-item.spec.tsx | 141 + .../__tests__/rule-detail.spec.tsx | 145 + .../__tests__/upgrade-banner.spec.tsx | 29 + .../use-indexing-status-polling.spec.ts | 179 ++ .../embedding-process/__tests__/utils.spec.ts | 140 + .../{ => __tests__}/index.spec.tsx | 109 +- .../{ => __tests__}/index.spec.tsx | 150 +- .../{ => __tests__}/index.spec.tsx | 37 +- .../{ => __tests__}/file-list-item.spec.tsx | 6 +- .../{ => __tests__}/upload-dropzone.spec.tsx | 39 +- .../{ => __tests__}/use-file-upload.spec.tsx | 12 +- .../{ => __tests__}/index.spec.tsx | 196 +- .../create/step-one/__tests__/index.spec.tsx | 561 ++++ .../step-one/__tests__/upgrade-card.spec.tsx | 89 + .../data-source-type-selector.spec.tsx | 66 + .../__tests__/next-step-button.spec.tsx | 48 + .../__tests__/preview-panel.spec.tsx | 119 + .../hooks/__tests__/use-preview-state.spec.ts | 60 + .../datasets/create/step-one/index.spec.tsx | 1204 -------- .../step-three/{ => __tests__}/index.spec.tsx | 174 +- .../step-two/{ => __tests__}/index.spec.tsx | 452 ++- .../general-chunking-options.spec.tsx | 168 + .../__tests__/indexing-mode-section.spec.tsx | 213 ++ .../components/__tests__/inputs.spec.tsx | 92 + .../components/__tests__/option-card.spec.tsx | 160 + .../__tests__/parent-child-options.spec.tsx | 150 + .../__tests__/preview-panel.spec.tsx | 166 + .../__tests__/step-two-footer.spec.tsx | 46 + .../step-two/hooks/__tests__/escape.spec.ts | 75 + .../step-two/hooks/__tests__/unescape.spec.ts | 96 + .../__tests__/use-document-creation.spec.ts | 186 ++ .../__tests__/use-indexing-config.spec.ts | 161 + .../__tests__/use-indexing-estimate.spec.ts | 127 + .../hooks/__tests__/use-preview-state.spec.ts | 198 ++ .../__tests__/use-segmentation-state.spec.ts | 372 +++ .../{ => __tests__}/index.spec.tsx | 95 +- .../{ => __tests__}/index.spec.tsx | 147 +- .../stepper/{ => __tests__}/index.spec.tsx | 145 +- .../create/stepper/__tests__/step.spec.tsx | 32 + .../{ => __tests__}/index.spec.tsx | 127 +- .../top-bar/{ => __tests__}/index.spec.tsx | 102 +- .../website/{ => __tests__}/base.spec.tsx | 23 +- .../create/website/__tests__/index.spec.tsx | 286 ++ .../create/website/__tests__/no-data.spec.tsx | 185 ++ .../create/website/__tests__/preview.spec.tsx | 197 ++ .../__tests__/checkbox-with-label.spec.tsx | 43 + .../__tests__/crawled-result-item.spec.tsx | 43 + .../base/__tests__/crawled-result.spec.tsx | 313 ++ .../website/base/__tests__/crawling.spec.tsx | 20 + .../base/__tests__/error-message.spec.tsx | 29 + .../website/base/__tests__/field.spec.tsx | 46 + .../website/base/__tests__/header.spec.tsx | 45 + .../website/base/__tests__/input.spec.tsx | 52 + .../base/__tests__/options-wrap.spec.tsx | 43 + .../base/{ => __tests__}/url-input.spec.tsx | 30 +- .../firecrawl/{ => __tests__}/index.spec.tsx | 30 +- .../{ => __tests__}/options.spec.tsx | 22 +- .../jina-reader/{ => __tests__}/base.spec.tsx | 77 +- .../{ => __tests__}/index.spec.tsx | 207 +- .../jina-reader/__tests__/options.spec.tsx | 191 ++ .../base/__tests__/url-input.spec.tsx | 192 ++ .../watercrawl/{ => __tests__}/index.spec.tsx | 212 +- .../watercrawl/__tests__/options.spec.tsx | 276 ++ .../documents/{ => __tests__}/index.spec.tsx | 14 +- .../documents/__tests__/status-filter.spec.ts | 156 + .../{ => __tests__}/documents-header.spec.tsx | 2 +- .../{ => __tests__}/empty-element.spec.tsx | 2 +- .../components/{ => __tests__}/icons.spec.tsx | 2 +- .../{ => __tests__}/operations.spec.tsx | 76 +- .../{ => __tests__}/rename-modal.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 3 +- .../document-source-icon.spec.tsx | 2 +- .../document-table-row.spec.tsx | 3 +- .../{ => __tests__}/sort-header.spec.tsx | 2 +- .../components/{ => __tests__}/utils.spec.tsx | 2 +- .../__tests__/use-document-actions.spec.ts | 231 ++ .../use-document-actions.spec.tsx | 2 +- .../use-document-selection.spec.ts | 2 +- .../{ => __tests__}/use-document-sort.spec.ts | 2 +- .../{ => __tests__}/index.spec.tsx | 110 +- .../__tests__/left-header.spec.tsx | 110 + .../__tests__/step-indicator.spec.tsx | 32 + .../actions/{ => __tests__}/index.spec.tsx | 117 +- .../__tests__/datasource-icon.spec.tsx | 33 + .../__tests__/hooks.spec.tsx | 141 + .../{ => __tests__}/index.spec.tsx | 214 +- .../__tests__/option-card.spec.tsx | 110 + .../base/__tests__/header.spec.tsx | 48 + .../{ => __tests__}/index.spec.tsx | 158 +- .../__tests__/item.spec.tsx | 32 + .../__tests__/list.spec.tsx | 37 + .../__tests__/trigger.spec.tsx | 36 + .../data-source/base/header.spec.tsx | 658 ---- .../local-file/{ => __tests__}/index.spec.tsx | 10 +- .../{ => __tests__}/file-list-item.spec.tsx | 6 +- .../{ => __tests__}/upload-dropzone.spec.tsx | 40 +- .../use-local-file-upload.spec.tsx | 15 +- .../{ => __tests__}/index.spec.tsx | 249 +- .../online-documents/__tests__/title.spec.tsx | 10 + .../{ => __tests__}/index.spec.tsx | 255 +- .../page-selector/__tests__/utils.spec.ts | 100 + .../online-drive/__tests__/header.spec.tsx | 22 + .../{ => __tests__}/index.spec.tsx | 363 +-- .../online-drive/__tests__/utils.spec.ts | 105 + .../connect/{ => __tests__}/index.spec.tsx | 132 +- .../file-list/{ => __tests__}/index.spec.tsx | 157 +- .../header/{ => __tests__}/index.spec.tsx | 147 +- .../breadcrumbs/__tests__/bucket.spec.tsx | 57 + .../breadcrumbs/__tests__/drive.spec.tsx | 61 + .../{ => __tests__}/index.spec.tsx | 160 +- .../breadcrumbs/__tests__/item.spec.tsx | 48 + .../dropdown/{ => __tests__}/index.spec.tsx | 123 +- .../dropdown/__tests__/item.spec.tsx | 44 + .../dropdown/__tests__/menu.spec.tsx | 79 + .../list/__tests__/empty-folder.spec.tsx | 10 + .../__tests__/empty-search-result.spec.tsx | 31 + .../list/__tests__/file-icon.spec.tsx | 29 + .../list/{ => __tests__}/index.spec.tsx | 223 +- .../file-list/list/__tests__/item.spec.tsx | 90 + .../file-list/list/__tests__/utils.spec.ts | 79 + .../file-list/list/empty-folder.spec.tsx | 38 - .../data-source/store/__tests__/index.spec.ts | 96 + .../store/__tests__/provider.spec.tsx | 89 + .../store/slices/__tests__/common.spec.ts | 29 + .../store/slices/__tests__/local-file.spec.ts | 49 + .../slices/__tests__/online-document.spec.ts | 55 + .../slices/__tests__/online-drive.spec.ts | 79 + .../slices/__tests__/website-crawl.spec.ts | 65 + .../{ => __tests__}/index.spec.tsx | 292 +- .../__tests__/checkbox-with-label.spec.tsx | 50 + .../__tests__/crawled-result-item.spec.tsx | 69 + .../base/__tests__/crawled-result.spec.tsx | 214 ++ .../base/__tests__/crawling.spec.tsx | 21 + .../base/__tests__/error-message.spec.tsx | 26 + .../base/{ => __tests__}/index.spec.tsx | 159 +- .../options/{ => __tests__}/index.spec.tsx | 205 +- .../__tests__/use-add-documents-steps.spec.ts | 50 + .../__tests__/use-datasource-actions.spec.ts | 204 ++ .../__tests__/use-datasource-options.spec.ts | 58 + .../__tests__/use-datasource-store.spec.ts | 207 ++ .../__tests__/use-datasource-ui-state.spec.ts | 205 ++ .../{ => __tests__}/chunk-preview.spec.tsx | 5 +- .../preview/__tests__/file-preview.spec.tsx | 68 + .../online-document-preview.spec.tsx | 4 +- .../preview/__tests__/web-preview.spec.tsx | 48 + .../preview/file-preview.spec.tsx | 320 -- .../preview/web-preview.spec.tsx | 256 -- .../__tests__/actions.spec.tsx | 69 + .../{ => __tests__}/components.spec.tsx | 180 +- .../__tests__/header.spec.tsx | 57 + .../process-documents/__tests__/hooks.spec.ts | 52 + .../{ => __tests__}/index.spec.tsx | 132 +- .../processing/{ => __tests__}/index.spec.tsx | 127 +- .../{ => __tests__}/index.spec.tsx | 181 +- .../{ => __tests__}/rule-detail.spec.tsx | 90 +- .../{ => __tests__}/preview-panel.spec.tsx | 4 +- .../{ => __tests__}/step-one-content.spec.tsx | 17 +- .../step-three-content.spec.tsx | 4 +- .../{ => __tests__}/step-two-content.spec.tsx | 4 +- .../__tests__/datasource-info-builder.spec.ts | 104 + .../{ => __tests__}/document-title.spec.tsx | 31 +- .../documents/detail/__tests__/index.spec.tsx | 454 +++ .../{ => __tests__}/new-segment.spec.tsx | 310 +- .../{ => __tests__}/csv-downloader.spec.tsx | 37 +- .../{ => __tests__}/csv-uploader.spec.tsx | 241 +- .../{ => __tests__}/index.spec.tsx | 40 +- .../child-segment-detail.spec.tsx | 65 +- .../child-segment-list.spec.tsx | 79 +- .../{ => __tests__}/display-toggle.spec.tsx | 24 +- .../completed/{ => __tests__}/index.spec.tsx | 1115 ++----- .../new-child-segment.spec.tsx | 82 +- .../{ => __tests__}/segment-detail.spec.tsx | 125 +- .../{ => __tests__}/segment-list.spec.tsx | 81 +- .../{ => __tests__}/status-item.spec.tsx | 27 +- .../{ => __tests__}/action-buttons.spec.tsx | 52 +- .../{ => __tests__}/add-another.spec.tsx | 31 +- .../{ => __tests__}/batch-action.spec.tsx | 63 +- .../{ => __tests__}/chunk-content.spec.tsx | 39 +- .../common/{ => __tests__}/dot.spec.tsx | 16 +- .../common/__tests__/drawer.spec.tsx | 135 + .../common/{ => __tests__}/empty.spec.tsx | 33 +- .../full-screen-drawer.spec.tsx | 39 +- .../common/{ => __tests__}/keywords.spec.tsx | 36 +- .../regeneration-modal.spec.tsx | 50 +- .../segment-index-tag.spec.tsx | 48 +- .../common/__tests__/summary-label.spec.tsx | 20 + .../common/__tests__/summary-status.spec.tsx | 27 + .../common/__tests__/summary-text.spec.tsx | 42 + .../common/__tests__/summary.spec.tsx | 233 ++ .../common/{ => __tests__}/tag.spec.tsx | 36 +- .../__tests__/drawer-group.spec.tsx | 106 + .../components/__tests__/menu-bar.spec.tsx | 95 + .../__tests__/segment-list-content.spec.tsx | 103 + .../use-child-segment-data.spec.ts | 181 +- .../hooks/__tests__/use-modal-state.spec.ts | 146 + .../hooks/__tests__/use-search-filter.spec.ts | 124 + .../use-segment-list-data.spec.ts | 67 +- .../__tests__/use-segment-selection.spec.ts | 159 + .../{ => __tests__}/chunk-content.spec.tsx | 32 +- .../{ => __tests__}/index.spec.tsx | 74 +- .../full-doc-list-skeleton.spec.tsx | 20 +- .../general-list-skeleton.spec.tsx | 36 +- .../paragraph-list-skeleton.spec.tsx | 28 +- .../parent-chunk-card-skeleton.spec.tsx | 27 +- .../embedding/{ => __tests__}/index.spec.tsx | 8 +- .../{ => __tests__}/progress-bar.spec.tsx | 2 +- .../{ => __tests__}/rule-detail.spec.tsx | 4 +- .../{ => __tests__}/segment-progress.spec.tsx | 2 +- .../{ => __tests__}/status-header.spec.tsx | 2 +- .../use-embedding-status.spec.tsx | 2 +- .../skeleton/{ => __tests__}/index.spec.tsx | 2 +- .../metadata/{ => __tests__}/index.spec.tsx | 75 +- .../{ => __tests__}/index.spec.tsx | 63 +- .../document-settings.spec.tsx | 56 +- .../settings/{ => __tests__}/index.spec.tsx | 28 +- .../{ => __tests__}/index.spec.tsx | 84 +- .../{ => __tests__}/left-header.spec.tsx | 35 +- .../{ => __tests__}/actions.spec.tsx | 35 +- .../process-documents/__tests__/hooks.spec.ts | 70 + .../{ => __tests__}/index.spec.tsx | 71 +- .../use-document-list-query-state.spec.ts | 439 +++ .../use-documents-page-state.spec.ts | 711 +++++ .../status-item/__tests__/hooks.spec.ts | 119 + .../{ => __tests__}/index.spec.tsx | 17 +- .../{ => __tests__}/Form.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 5 +- .../{ => __tests__}/index.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../connector/{ => __tests__}/index.spec.tsx | 5 +- .../__tests__/ExternalApiSelect.spec.tsx | 104 + .../__tests__/ExternalApiSelection.spec.tsx | 112 + .../create/__tests__/InfoPanel.spec.tsx | 94 + .../__tests__/KnowledgeBaseInfo.spec.tsx | 153 + .../__tests__/RetrievalSettings.spec.tsx | 92 + .../create/{ => __tests__}/index.spec.tsx | 20 +- .../extra-info/{ => __tests__}/index.spec.tsx | 29 +- .../{ => __tests__}/statistics.spec.tsx | 13 +- .../api-access/__tests__/card.spec.tsx | 186 ++ .../api-access/{ => __tests__}/index.spec.tsx | 20 +- .../service-api/__tests__/card.spec.tsx | 168 + .../{ => __tests__}/index.spec.tsx | 58 +- .../__tests__/formatted.spec.tsx | 27 + .../flavours/__tests__/edit-slice.spec.tsx | 190 ++ .../flavours/__tests__/preview-slice.spec.tsx | 113 + .../flavours/__tests__/shared.spec.tsx | 85 + .../hit-testing/__tests__/index.spec.tsx | 1067 +++++++ .../modify-external-retrieval-modal.spec.tsx | 126 + .../__tests__/modify-retrieval-modal.spec.tsx | 108 + .../__tests__/child-chunks-item.spec.tsx | 97 + .../__tests__/chunk-detail-modal.spec.tsx | 137 + .../__tests__/empty-records.spec.tsx | 33 + .../components/__tests__/mask.spec.tsx | 33 + .../components/__tests__/records.spec.tsx | 95 + .../__tests__/result-item-external.spec.tsx | 173 ++ .../__tests__/result-item-footer.spec.tsx | 70 + .../__tests__/result-item-meta.spec.tsx | 80 + .../components/__tests__/result-item.spec.tsx | 144 + .../components/__tests__/score.spec.tsx | 92 + .../query-input/__tests__/index.spec.tsx | 111 + .../query-input/__tests__/textarea.spec.tsx | 120 + .../datasets/hit-testing/index.spec.tsx | 2704 ----------------- .../__tests__/extension-to-file-type.spec.ts | 119 + .../list/{ => __tests__}/datasets.spec.tsx | 16 +- .../list/{ => __tests__}/index.spec.tsx | 33 +- .../dataset-card/__tests__/index.spec.tsx | 422 +++ .../{ => __tests__}/operation-item.spec.tsx | 2 +- .../{ => __tests__}/operations.spec.tsx | 2 +- .../{ => __tests__}/corner-labels.spec.tsx | 2 +- .../dataset-card-footer.spec.tsx | 2 +- .../dataset-card-header.spec.tsx | 2 +- .../dataset-card-modals.spec.tsx | 4 +- .../{ => __tests__}/description.spec.tsx | 2 +- .../operations-popover.spec.tsx | 6 +- .../{ => __tests__}/tag-area.spec.tsx | 2 +- .../use-dataset-card-state.spec.ts | 4 +- .../datasets/list/dataset-card/index.spec.tsx | 256 -- .../{ => __tests__}/index.spec.tsx | 2 +- .../new-dataset-card/__tests__/index.spec.tsx | 134 + .../{ => __tests__}/option.spec.tsx | 2 +- .../list/new-dataset-card/index.spec.tsx | 76 - .../add-metadata-button.spec.tsx | 2 +- .../base/{ => __tests__}/date-picker.spec.tsx | 2 +- .../{ => __tests__}/add-row.spec.tsx | 10 +- .../{ => __tests__}/edit-row.spec.tsx | 14 +- .../{ => __tests__}/edited-beacon.spec.tsx | 3 +- .../{ => __tests__}/input-combined.spec.tsx | 6 +- .../input-has-set-multiple-value.spec.tsx | 3 +- .../{ => __tests__}/label.spec.tsx | 2 +- .../{ => __tests__}/modal.spec.tsx | 15 +- .../use-batch-edit-document-metadata.spec.ts | 5 +- .../use-check-metadata-name.spec.ts | 2 +- .../use-edit-dataset-metadata.spec.ts | 8 +- .../use-metadata-document.spec.ts | 8 +- .../{ => __tests__}/create-content.spec.tsx | 10 +- .../create-metadata-modal.spec.tsx | 8 +- .../dataset-metadata-drawer.spec.tsx | 13 +- .../{ => __tests__}/field.spec.tsx | 2 +- .../select-metadata-modal.spec.tsx | 10 +- .../{ => __tests__}/select-metadata.spec.tsx | 8 +- .../{ => __tests__}/field.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/info-group.spec.tsx | 15 +- .../{ => __tests__}/no-data.spec.tsx | 2 +- .../utils/{ => __tests__}/get-icon.spec.ts | 4 +- .../preview/__tests__/container.spec.tsx | 173 ++ .../preview/__tests__/header.spec.tsx | 141 + .../preview/{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/option-card.spec.tsx | 6 +- .../__tests__/summary-index-setting.spec.tsx | 226 ++ .../{ => __tests__}/hooks.spec.tsx | 6 +- .../{ => __tests__}/index.spec.tsx | 4 +- .../form/{ => __tests__}/index.spec.tsx | 6 +- .../basic-info-section.spec.tsx | 6 +- .../external-knowledge-section.spec.tsx | 4 +- .../{ => __tests__}/indexing-section.spec.tsx | 4 +- .../{ => __tests__}/use-form-state.spec.ts | 4 +- .../{ => __tests__}/index.spec.tsx | 7 +- .../{ => __tests__}/keyword-number.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 24 +- .../{ => __tests__}/member-item.spec.tsx | 4 +- .../{ => __tests__}/permission-item.spec.tsx | 2 +- .../utils/{ => __tests__}/index.spec.ts | 4 +- web/eslint-suppressions.json | 80 - 388 files changed, 22637 insertions(+), 15567 deletions(-) create mode 100644 web/__tests__/datasets/create-dataset-flow.test.tsx create mode 100644 web/__tests__/datasets/dataset-settings-flow.test.tsx create mode 100644 web/__tests__/datasets/document-management.test.tsx create mode 100644 web/__tests__/datasets/external-knowledge-base.test.tsx create mode 100644 web/__tests__/datasets/hit-testing-flow.test.tsx create mode 100644 web/__tests__/datasets/metadata-management-flow.test.tsx create mode 100644 web/__tests__/datasets/pipeline-datasource-flow.test.tsx create mode 100644 web/__tests__/datasets/segment-crud.test.tsx create mode 100644 web/app/components/datasets/__tests__/chunk.spec.tsx rename web/app/components/datasets/{ => __tests__}/loading.spec.tsx (92%) rename web/app/components/datasets/{ => __tests__}/no-linked-apps-panel.spec.tsx (78%) rename web/app/components/datasets/api/{ => __tests__}/index.spec.tsx (95%) delete mode 100644 web/app/components/datasets/chunk.spec.tsx rename web/app/components/datasets/common/{ => __tests__}/check-rerank-model.spec.ts (99%) rename web/app/components/datasets/common/{ => __tests__}/chunking-mode-label.spec.tsx (97%) rename web/app/components/datasets/common/{ => __tests__}/credential-icon.spec.tsx (99%) rename web/app/components/datasets/common/{ => __tests__}/document-file-icon.spec.tsx (98%) create mode 100644 web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx rename web/app/components/datasets/common/document-picker/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/datasets/common/document-picker/{ => __tests__}/preview-document-picker.spec.tsx (87%) rename web/app/components/datasets/common/document-status-with-action/{ => __tests__}/auto-disabled-document.spec.tsx (98%) rename web/app/components/datasets/common/document-status-with-action/{ => __tests__}/index-failed.spec.tsx (99%) rename web/app/components/datasets/common/document-status-with-action/{ => __tests__}/status-with-action.spec.tsx (99%) rename web/app/components/datasets/common/economical-retrieval-method-config/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/datasets/common/image-list/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/datasets/common/image-list/{ => __tests__}/more.spec.tsx (99%) rename web/app/components/datasets/common/image-previewer/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/common/image-uploader/{ => __tests__}/store.spec.tsx (99%) rename web/app/components/datasets/common/image-uploader/{ => __tests__}/utils.spec.ts (99%) rename web/app/components/datasets/common/image-uploader/hooks/{ => __tests__}/use-upload.spec.tsx (99%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/{ => __tests__}/image-input.spec.tsx (96%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/{ => __tests__}/image-item.spec.tsx (98%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/{ => __tests__}/image-input.spec.tsx (96%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/{ => __tests__}/image-item.spec.tsx (98%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/common/retrieval-method-config/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/common/retrieval-method-info/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/datasets/common/retrieval-param-config/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/datasets/create-from-pipeline/{ => __tests__}/footer.spec.tsx (81%) rename web/app/components/datasets/create-from-pipeline/{ => __tests__}/header.spec.tsx (72%) rename web/app/components/datasets/create-from-pipeline/{ => __tests__}/index.spec.tsx (77%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/{ => __tests__}/dsl-confirm-modal.spec.tsx (80%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/{ => __tests__}/header.spec.tsx (73%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/{ => __tests__}/uploader.spec.tsx (81%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/{ => __tests__}/use-dsl-import.spec.tsx (99%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/{ => __tests__}/index.spec.tsx (80%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/{ => __tests__}/item.spec.tsx (74%) rename web/app/components/datasets/create-from-pipeline/list/{ => __tests__}/built-in-pipeline-list.spec.tsx (83%) rename web/app/components/datasets/create-from-pipeline/list/{ => __tests__}/create-card.spec.tsx (83%) rename web/app/components/datasets/create-from-pipeline/list/{ => __tests__}/customized-list.spec.tsx (78%) rename web/app/components/datasets/create-from-pipeline/list/{ => __tests__}/index.spec.tsx (69%) rename web/app/components/datasets/create-from-pipeline/list/template-card/{ => __tests__}/actions.spec.tsx (79%) rename web/app/components/datasets/create-from-pipeline/list/template-card/{ => __tests__}/content.spec.tsx (81%) rename web/app/components/datasets/create-from-pipeline/list/template-card/{ => __tests__}/edit-pipeline-info.spec.tsx (91%) rename web/app/components/datasets/create-from-pipeline/list/template-card/{ => __tests__}/index.spec.tsx (91%) rename web/app/components/datasets/create-from-pipeline/list/template-card/{ => __tests__}/operations.spec.tsx (82%) rename web/app/components/datasets/create-from-pipeline/list/template-card/details/{ => __tests__}/chunk-structure-card.spec.tsx (83%) rename web/app/components/datasets/create-from-pipeline/list/template-card/details/{ => __tests__}/hooks.spec.tsx (81%) rename web/app/components/datasets/create-from-pipeline/list/template-card/details/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/datasets/create/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/datasets/create/embedding-process/{ => __tests__}/index.spec.tsx (89%) create mode 100644 web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx create mode 100644 web/app/components/datasets/create/embedding-process/__tests__/rule-detail.spec.tsx create mode 100644 web/app/components/datasets/create/embedding-process/__tests__/upgrade-banner.spec.tsx create mode 100644 web/app/components/datasets/create/embedding-process/__tests__/use-indexing-status-polling.spec.ts create mode 100644 web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts rename web/app/components/datasets/create/empty-dataset-creation-modal/{ => __tests__}/index.spec.tsx (92%) rename web/app/components/datasets/create/file-preview/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/datasets/create/file-uploader/{ => __tests__}/index.spec.tsx (87%) rename web/app/components/datasets/create/file-uploader/components/{ => __tests__}/file-list-item.spec.tsx (98%) rename web/app/components/datasets/create/file-uploader/components/{ => __tests__}/upload-dropzone.spec.tsx (84%) rename web/app/components/datasets/create/file-uploader/hooks/{ => __tests__}/use-file-upload.spec.tsx (99%) rename web/app/components/datasets/create/notion-page-preview/{ => __tests__}/index.spec.tsx (86%) create mode 100644 web/app/components/datasets/create/step-one/__tests__/index.spec.tsx create mode 100644 web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx create mode 100644 web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx create mode 100644 web/app/components/datasets/create/step-one/components/__tests__/next-step-button.spec.tsx create mode 100644 web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx create mode 100644 web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts delete mode 100644 web/app/components/datasets/create/step-one/index.spec.tsx rename web/app/components/datasets/create/step-three/{ => __tests__}/index.spec.tsx (84%) rename web/app/components/datasets/create/step-two/{ => __tests__}/index.spec.tsx (81%) create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/step-two-footer.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-segmentation-state.spec.ts rename web/app/components/datasets/create/step-two/language-select/{ => __tests__}/index.spec.tsx (89%) rename web/app/components/datasets/create/step-two/preview-item/{ => __tests__}/index.spec.tsx (88%) rename web/app/components/datasets/create/stepper/{ => __tests__}/index.spec.tsx (82%) create mode 100644 web/app/components/datasets/create/stepper/__tests__/step.spec.tsx rename web/app/components/datasets/create/stop-embedding-modal/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/datasets/create/top-bar/{ => __tests__}/index.spec.tsx (83%) rename web/app/components/datasets/create/website/{ => __tests__}/base.spec.tsx (94%) create mode 100644 web/app/components/datasets/create/website/__tests__/index.spec.tsx create mode 100644 web/app/components/datasets/create/website/__tests__/no-data.spec.tsx create mode 100644 web/app/components/datasets/create/website/__tests__/preview.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/checkbox-with-label.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/crawled-result.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/field.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/header.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/input.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx rename web/app/components/datasets/create/website/base/{ => __tests__}/url-input.spec.tsx (86%) rename web/app/components/datasets/create/website/firecrawl/{ => __tests__}/index.spec.tsx (90%) rename web/app/components/datasets/create/website/firecrawl/{ => __tests__}/options.spec.tsx (90%) rename web/app/components/datasets/create/website/jina-reader/{ => __tests__}/base.spec.tsx (82%) rename web/app/components/datasets/create/website/jina-reader/{ => __tests__}/index.spec.tsx (91%) create mode 100644 web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx create mode 100644 web/app/components/datasets/create/website/jina-reader/base/__tests__/url-input.spec.tsx rename web/app/components/datasets/create/website/watercrawl/{ => __tests__}/index.spec.tsx (91%) create mode 100644 web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx rename web/app/components/datasets/documents/{ => __tests__}/index.spec.tsx (98%) create mode 100644 web/app/components/datasets/documents/__tests__/status-filter.spec.ts rename web/app/components/datasets/documents/components/{ => __tests__}/documents-header.spec.tsx (99%) rename web/app/components/datasets/documents/components/{ => __tests__}/empty-element.spec.tsx (98%) rename web/app/components/datasets/documents/components/{ => __tests__}/icons.spec.tsx (97%) rename web/app/components/datasets/documents/components/{ => __tests__}/operations.spec.tsx (87%) rename web/app/components/datasets/documents/components/{ => __tests__}/rename-modal.spec.tsx (99%) rename web/app/components/datasets/documents/components/document-list/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/documents/components/document-list/components/{ => __tests__}/document-source-icon.spec.tsx (99%) rename web/app/components/datasets/documents/components/document-list/components/{ => __tests__}/document-table-row.spec.tsx (99%) rename web/app/components/datasets/documents/components/document-list/components/{ => __tests__}/sort-header.spec.tsx (98%) rename web/app/components/datasets/documents/components/document-list/components/{ => __tests__}/utils.spec.tsx (98%) create mode 100644 web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts rename web/app/components/datasets/documents/components/document-list/hooks/{ => __tests__}/use-document-actions.spec.tsx (99%) rename web/app/components/datasets/documents/components/document-list/hooks/{ => __tests__}/use-document-selection.spec.ts (99%) rename web/app/components/datasets/documents/components/document-list/hooks/{ => __tests__}/use-document-sort.spec.ts (99%) rename web/app/components/datasets/documents/create-from-pipeline/{ => __tests__}/index.spec.tsx (95%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/__tests__/step-indicator.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/actions/{ => __tests__}/index.spec.tsx (89%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/datasource-icon.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source-options/{ => __tests__}/index.spec.tsx (90%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/{ => __tests__}/index.spec.tsx (89%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/list.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx delete mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/{ => __tests__}/file-list-item.spec.tsx (98%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/{ => __tests__}/upload-dropzone.spec.tsx (83%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/{ => __tests__}/use-local-file-upload.spec.tsx (98%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/{ => __tests__}/index.spec.tsx (87%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/{ => __tests__}/index.spec.tsx (90%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/header.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/{ => __tests__}/index.spec.tsx (86%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/utils.spec.ts rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/{ => __tests__}/index.spec.tsx (84%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/{ => __tests__}/index.spec.tsx (86%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/{ => __tests__}/index.spec.tsx (87%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/{ => __tests__}/index.spec.tsx (90%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/item.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/{ => __tests__}/index.spec.tsx (89%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-folder.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/{ => __tests__}/index.spec.tsx (92%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/item.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/utils.spec.ts delete mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/provider.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/common.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts rename web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/{ => __tests__}/index.spec.tsx (86%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/checkbox-with-label.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawling.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/error-message.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/{ => __tests__}/index.spec.tsx (88%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/{ => __tests__}/index.spec.tsx (88%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts rename web/app/components/datasets/documents/create-from-pipeline/preview/{ => __tests__}/chunk-preview.spec.tsx (99%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/preview/{ => __tests__}/online-document-preview.spec.tsx (99%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/web-preview.spec.tsx delete mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx delete mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/process-documents/{ => __tests__}/components.spec.tsx (83%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts rename web/app/components/datasets/documents/create-from-pipeline/process-documents/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/datasets/documents/create-from-pipeline/processing/{ => __tests__}/index.spec.tsx (88%) rename web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/{ => __tests__}/index.spec.tsx (90%) rename web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/{ => __tests__}/rule-detail.spec.tsx (85%) rename web/app/components/datasets/documents/create-from-pipeline/steps/{ => __tests__}/preview-panel.spec.tsx (98%) rename web/app/components/datasets/documents/create-from-pipeline/steps/{ => __tests__}/step-one-content.spec.tsx (97%) rename web/app/components/datasets/documents/create-from-pipeline/steps/{ => __tests__}/step-three-content.spec.tsx (96%) rename web/app/components/datasets/documents/create-from-pipeline/steps/{ => __tests__}/step-two-content.spec.tsx (97%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/utils/__tests__/datasource-info-builder.spec.ts rename web/app/components/datasets/documents/detail/{ => __tests__}/document-title.spec.tsx (87%) create mode 100644 web/app/components/datasets/documents/detail/__tests__/index.spec.tsx rename web/app/components/datasets/documents/detail/{ => __tests__}/new-segment.spec.tsx (61%) rename web/app/components/datasets/documents/detail/batch-modal/{ => __tests__}/csv-downloader.spec.tsx (91%) rename web/app/components/datasets/documents/detail/batch-modal/{ => __tests__}/csv-uploader.spec.tsx (68%) rename web/app/components/datasets/documents/detail/batch-modal/{ => __tests__}/index.spec.tsx (89%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/child-segment-detail.spec.tsx (87%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/child-segment-list.spec.tsx (89%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/display-toggle.spec.tsx (88%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/index.spec.tsx (51%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/new-child-segment.spec.tsx (90%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/segment-detail.spec.tsx (89%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/segment-list.spec.tsx (90%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/status-item.spec.tsx (86%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/action-buttons.spec.tsx (93%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/add-another.spec.tsx (89%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/batch-action.spec.tsx (86%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/chunk-content.spec.tsx (91%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/dot.spec.tsx (82%) create mode 100644 web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/empty.spec.tsx (86%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/full-screen-drawer.spec.tsx (90%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/keywords.spec.tsx (91%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/regeneration-modal.spec.tsx (91%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/segment-index-tag.spec.tsx (85%) create mode 100644 web/app/components/datasets/documents/detail/completed/common/__tests__/summary-label.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/tag.spec.tsx (84%) create mode 100644 web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx rename web/app/components/datasets/documents/detail/completed/hooks/{ => __tests__}/use-child-segment-data.spec.ts (76%) create mode 100644 web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts create mode 100644 web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-search-filter.spec.ts rename web/app/components/datasets/documents/detail/completed/hooks/{ => __tests__}/use-segment-list-data.spec.ts (92%) create mode 100644 web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts rename web/app/components/datasets/documents/detail/completed/segment-card/{ => __tests__}/chunk-content.spec.tsx (92%) rename web/app/components/datasets/documents/detail/completed/segment-card/{ => __tests__}/index.spec.tsx (90%) rename web/app/components/datasets/documents/detail/completed/skeleton/{ => __tests__}/full-doc-list-skeleton.spec.tsx (90%) rename web/app/components/datasets/documents/detail/completed/skeleton/{ => __tests__}/general-list-skeleton.spec.tsx (88%) rename web/app/components/datasets/documents/detail/completed/skeleton/{ => __tests__}/paragraph-list-skeleton.spec.tsx (88%) rename web/app/components/datasets/documents/detail/completed/skeleton/{ => __tests__}/parent-chunk-card-skeleton.spec.tsx (87%) rename web/app/components/datasets/documents/detail/embedding/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/documents/detail/embedding/components/{ => __tests__}/progress-bar.spec.tsx (99%) rename web/app/components/datasets/documents/detail/embedding/components/{ => __tests__}/rule-detail.spec.tsx (98%) rename web/app/components/datasets/documents/detail/embedding/components/{ => __tests__}/segment-progress.spec.tsx (98%) rename web/app/components/datasets/documents/detail/embedding/components/{ => __tests__}/status-header.spec.tsx (99%) rename web/app/components/datasets/documents/detail/embedding/hooks/{ => __tests__}/use-embedding-status.spec.tsx (99%) rename web/app/components/datasets/documents/detail/embedding/skeleton/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/datasets/documents/detail/metadata/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/datasets/documents/detail/segment-add/{ => __tests__}/index.spec.tsx (90%) rename web/app/components/datasets/documents/detail/settings/{ => __tests__}/document-settings.spec.tsx (90%) rename web/app/components/datasets/documents/detail/settings/{ => __tests__}/index.spec.tsx (88%) rename web/app/components/datasets/documents/detail/settings/pipeline-settings/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/datasets/documents/detail/settings/pipeline-settings/{ => __tests__}/left-header.spec.tsx (84%) rename web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/{ => __tests__}/actions.spec.tsx (86%) create mode 100644 web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts rename web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/{ => __tests__}/index.spec.tsx (94%) create mode 100644 web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.ts create mode 100644 web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts create mode 100644 web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts rename web/app/components/datasets/documents/status-item/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/datasets/external-api/external-api-modal/{ => __tests__}/Form.spec.tsx (98%) rename web/app/components/datasets/external-api/external-api-modal/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/external-api/external-api-panel/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/external-api/external-knowledge-api-card/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/external-knowledge-base/connector/{ => __tests__}/index.spec.tsx (99%) create mode 100644 web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx create mode 100644 web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx create mode 100644 web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx create mode 100644 web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx create mode 100644 web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx rename web/app/components/datasets/external-knowledge-base/create/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/extra-info/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/datasets/extra-info/{ => __tests__}/statistics.spec.tsx (88%) create mode 100644 web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx rename web/app/components/datasets/extra-info/api-access/{ => __tests__}/index.spec.tsx (88%) create mode 100644 web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx rename web/app/components/datasets/extra-info/service-api/{ => __tests__}/index.spec.tsx (88%) create mode 100644 web/app/components/datasets/formatted-text/__tests__/formatted.spec.tsx create mode 100644 web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx create mode 100644 web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx create mode 100644 web/app/components/datasets/formatted-text/flavours/__tests__/shared.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/__tests__/index.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/empty-records.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/mask.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/score.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx delete mode 100644 web/app/components/datasets/hit-testing/index.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/utils/__tests__/extension-to-file-type.spec.ts rename web/app/components/datasets/list/{ => __tests__}/datasets.spec.tsx (97%) rename web/app/components/datasets/list/{ => __tests__}/index.spec.tsx (91%) create mode 100644 web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx rename web/app/components/datasets/list/dataset-card/{ => __tests__}/operation-item.spec.tsx (98%) rename web/app/components/datasets/list/dataset-card/{ => __tests__}/operations.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/corner-labels.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/dataset-card-footer.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/dataset-card-header.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/dataset-card-modals.spec.tsx (98%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/description.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/operations-popover.spec.tsx (97%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/tag-area.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/hooks/{ => __tests__}/use-dataset-card-state.spec.ts (99%) delete mode 100644 web/app/components/datasets/list/dataset-card/index.spec.tsx rename web/app/components/datasets/list/dataset-footer/{ => __tests__}/index.spec.tsx (97%) create mode 100644 web/app/components/datasets/list/new-dataset-card/__tests__/index.spec.tsx rename web/app/components/datasets/list/new-dataset-card/{ => __tests__}/option.spec.tsx (98%) delete mode 100644 web/app/components/datasets/list/new-dataset-card/index.spec.tsx rename web/app/components/datasets/metadata/{ => __tests__}/add-metadata-button.spec.tsx (98%) rename web/app/components/datasets/metadata/base/{ => __tests__}/date-picker.spec.tsx (99%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/add-row.spec.tsx (97%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/edit-row.spec.tsx (97%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/edited-beacon.spec.tsx (98%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/input-combined.spec.tsx (98%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/input-has-set-multiple-value.spec.tsx (98%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/label.spec.tsx (99%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/modal.spec.tsx (98%) rename web/app/components/datasets/metadata/hooks/{ => __tests__}/use-batch-edit-document-metadata.spec.ts (99%) rename web/app/components/datasets/metadata/hooks/{ => __tests__}/use-check-metadata-name.spec.ts (99%) rename web/app/components/datasets/metadata/hooks/{ => __tests__}/use-edit-dataset-metadata.spec.ts (97%) rename web/app/components/datasets/metadata/hooks/{ => __tests__}/use-metadata-document.spec.ts (98%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/create-content.spec.tsx (97%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/create-metadata-modal.spec.tsx (97%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/dataset-metadata-drawer.spec.tsx (98%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/field.spec.tsx (99%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/select-metadata-modal.spec.tsx (97%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/select-metadata.spec.tsx (98%) rename web/app/components/datasets/metadata/metadata-document/{ => __tests__}/field.spec.tsx (99%) rename web/app/components/datasets/metadata/metadata-document/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/metadata/metadata-document/{ => __tests__}/info-group.spec.tsx (96%) rename web/app/components/datasets/metadata/metadata-document/{ => __tests__}/no-data.spec.tsx (99%) rename web/app/components/datasets/metadata/utils/{ => __tests__}/get-icon.spec.ts (94%) create mode 100644 web/app/components/datasets/preview/__tests__/container.spec.tsx create mode 100644 web/app/components/datasets/preview/__tests__/header.spec.tsx rename web/app/components/datasets/preview/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/datasets/rename-modal/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/settings/{ => __tests__}/option-card.spec.tsx (98%) create mode 100644 web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx rename web/app/components/datasets/settings/chunk-structure/{ => __tests__}/hooks.spec.tsx (98%) rename web/app/components/datasets/settings/chunk-structure/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/settings/form/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/settings/form/components/{ => __tests__}/basic-info-section.spec.tsx (98%) rename web/app/components/datasets/settings/form/components/{ => __tests__}/external-knowledge-section.spec.tsx (99%) rename web/app/components/datasets/settings/form/components/{ => __tests__}/indexing-section.spec.tsx (99%) rename web/app/components/datasets/settings/form/hooks/{ => __tests__}/use-form-state.spec.ts (99%) rename web/app/components/datasets/settings/index-method/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/datasets/settings/index-method/{ => __tests__}/keyword-number.spec.tsx (98%) rename web/app/components/datasets/settings/permission-selector/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/datasets/settings/permission-selector/{ => __tests__}/member-item.spec.tsx (98%) rename web/app/components/datasets/settings/permission-selector/{ => __tests__}/permission-item.spec.tsx (98%) rename web/app/components/datasets/settings/utils/{ => __tests__}/index.spec.ts (98%) diff --git a/web/__tests__/datasets/create-dataset-flow.test.tsx b/web/__tests__/datasets/create-dataset-flow.test.tsx new file mode 100644 index 0000000000..e3a59edde6 --- /dev/null +++ b/web/__tests__/datasets/create-dataset-flow.test.tsx @@ -0,0 +1,301 @@ +/** + * Integration Test: Create Dataset Flow + * + * Tests cross-module data flow: step-one data → step-two hooks → creation params → API call + * Validates data contracts between steps. + */ + +import type { CustomFile } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +const mockCreateFirstDocument = vi.fn() +const mockCreateDocument = vi.fn() +vi.mock('@/service/knowledge/use-create-dataset', () => ({ + useCreateFirstDocument: () => ({ mutateAsync: mockCreateFirstDocument, isPending: false }), + useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }), + getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({ + workspace_id: 'ws-1', + pages: pages.map(p => p.page_id), + notion_credential_id: credentialId, + }), + getWebsiteInfo: (opts: { websitePages: { url: string }[], websiteCrawlProvider: string }) => ({ + urls: opts.websitePages.map(p => p.url), + only_main_content: true, + provider: opts.websiteCrawlProvider, + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => vi.fn(), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +// Import hooks after mocks +const { useSegmentationState, DEFAULT_SEGMENT_IDENTIFIER, DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP } + = await import('@/app/components/datasets/create/step-two/hooks') +const { useDocumentCreation, IndexingType } + = await import('@/app/components/datasets/create/step-two/hooks') + +const createMockFile = (overrides?: Partial): CustomFile => ({ + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 1024, + extension: '.txt', + mime_type: 'text/plain', + created_at: 0, + created_by: '', + ...overrides, +} as CustomFile) + +describe('Create Dataset Flow - Cross-Step Data Contract', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Step-One → Step-Two: Segmentation Defaults', () => { + it('should initialise with correct default segmentation values', () => { + const { result } = renderHook(() => useSegmentationState()) + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + expect(result.current.overlap).toBe(DEFAULT_OVERLAP) + expect(result.current.segmentationType).toBe(ProcessMode.general) + }) + + it('should produce valid process rule for general chunking', () => { + const { result } = renderHook(() => useSegmentationState()) + const processRule = result.current.getProcessRule(ChunkingMode.text) + + // mode should be segmentationType = ProcessMode.general = 'custom' + expect(processRule.mode).toBe('custom') + expect(processRule.rules.segmentation).toEqual({ + separator: '\n\n', // unescaped from \\n\\n + max_tokens: DEFAULT_MAXIMUM_CHUNK_LENGTH, + chunk_overlap: DEFAULT_OVERLAP, + }) + // rules is empty initially since no default config loaded + expect(processRule.rules.pre_processing_rules).toEqual([]) + }) + + it('should produce valid process rule for parent-child chunking', () => { + const { result } = renderHook(() => useSegmentationState()) + const processRule = result.current.getProcessRule(ChunkingMode.parentChild) + + expect(processRule.mode).toBe('hierarchical') + expect(processRule.rules.parent_mode).toBe('paragraph') + expect(processRule.rules.segmentation).toEqual({ + separator: '\n\n', + max_tokens: 1024, + }) + expect(processRule.rules.subchunk_segmentation).toEqual({ + separator: '\n', + max_tokens: 512, + }) + }) + }) + + describe('Step-Two → Creation API: Params Building', () => { + it('should build valid creation params for file upload workflow', () => { + const files = [createMockFile()] + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + const processRule = segResult.current.getProcessRule(ChunkingMode.text) + const retrievalConfig: RetrievalConfig = { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } + + const params = creationResult.current.buildCreationParams( + ChunkingMode.text, + 'English', + processRule, + retrievalConfig, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).not.toBeNull() + // File IDs come from file.id (not file.file.id) + expect(params!.data_source.type).toBe(DataSourceType.FILE) + expect(params!.data_source.info_list.file_info_list?.file_ids).toContain('file-1') + + expect(params!.indexing_technique).toBe(IndexingType.QUALIFIED) + expect(params!.doc_form).toBe(ChunkingMode.text) + expect(params!.doc_language).toBe('English') + expect(params!.embedding_model).toBe('text-embedding-ada-002') + expect(params!.embedding_model_provider).toBe('openai') + expect(params!.process_rule.mode).toBe('custom') + }) + + it('should validate params: overlap must not exceed maxChunkLength', () => { + const { result } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + // validateParams returns false (invalid) when overlap > maxChunkLength for general mode + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 100, + limitMaxChunkLength: 4000, + overlap: 200, // overlap > maxChunkLength + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + }) + expect(isValid).toBe(false) + }) + + it('should validate params: maxChunkLength must not exceed limit', () => { + const { result } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 5000, + limitMaxChunkLength: 4000, // limit < maxChunkLength + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + }) + expect(isValid).toBe(false) + }) + }) + + describe('Full Flow: Segmentation State → Process Rule → Creation Params Consistency', () => { + it('should keep segmentation values consistent across getProcessRule and buildCreationParams', () => { + const files = [createMockFile()] + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + // Change segmentation settings + act(() => { + segResult.current.setMaxChunkLength(2048) + segResult.current.setOverlap(100) + }) + + const processRule = segResult.current.getProcessRule(ChunkingMode.text) + expect(processRule.rules.segmentation.max_tokens).toBe(2048) + expect(processRule.rules.segmentation.chunk_overlap).toBe(100) + + const params = creationResult.current.buildCreationParams( + ChunkingMode.text, + 'Chinese', + processRule, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).not.toBeNull() + expect(params!.process_rule.rules.segmentation.max_tokens).toBe(2048) + expect(params!.process_rule.rules.segmentation.chunk_overlap).toBe(100) + expect(params!.doc_language).toBe('Chinese') + }) + + it('should support parent-child mode through the full pipeline', () => { + const files = [createMockFile()] + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + const processRule = segResult.current.getProcessRule(ChunkingMode.parentChild) + const params = creationResult.current.buildCreationParams( + ChunkingMode.parentChild, + 'English', + processRule, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).not.toBeNull() + expect(params!.doc_form).toBe(ChunkingMode.parentChild) + expect(params!.process_rule.mode).toBe('hierarchical') + expect(params!.process_rule.rules.parent_mode).toBe('paragraph') + expect(params!.process_rule.rules.subchunk_segmentation).toBeDefined() + }) + }) +}) diff --git a/web/__tests__/datasets/dataset-settings-flow.test.tsx b/web/__tests__/datasets/dataset-settings-flow.test.tsx new file mode 100644 index 0000000000..607cd8c2d5 --- /dev/null +++ b/web/__tests__/datasets/dataset-settings-flow.test.tsx @@ -0,0 +1,451 @@ +/** + * Integration Test: Dataset Settings Flow + * + * Tests cross-module data contracts in the dataset settings form: + * useFormState hook ↔ index method config ↔ retrieval config ↔ permission state. + * + * The unit-level use-form-state.spec.ts validates the hook in isolation. + * This integration test verifies that changing one configuration dimension + * correctly cascades to dependent parts (index method → retrieval config, + * permission → member list visibility, embedding model → embedding available state). + */ + +import type { DataSet } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { act, renderHook, waitFor } from '@testing-library/react' +import { IndexingType } from '@/app/components/datasets/create/step-two' +import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +// --- Mocks --- + +const mockMutateDatasets = vi.fn() +const mockInvalidDatasetList = vi.fn() +const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({}) + +vi.mock('@/context/app-context', () => ({ + useSelector: () => false, +})) + +vi.mock('@/service/datasets', () => ({ + updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => mockInvalidDatasetList, +})) + +vi.mock('@/service/use-common', () => ({ + useMembers: () => ({ + data: { + accounts: [ + { id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { id: 'user-2', name: 'Bob', email: 'bob@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { id: 'user-3', name: 'Charlie', email: 'charlie@example.com', role: 'normal', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + ], + }, + }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: () => ({ data: [] }), +})) + +vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ + isReRankModelSelected: () => true, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +// --- Dataset factory --- + +const createMockDataset = (overrides?: Partial): DataSet => ({ + id: 'ds-settings-1', + name: 'Settings Test Dataset', + description: 'Integration test dataset', + permission: DatasetPermission.onlyMe, + icon_info: { + icon_type: 'emoji', + icon: '📙', + icon_background: '#FFF4ED', + icon_url: '', + }, + indexing_technique: 'high_quality', + indexing_status: 'completed', + data_source_type: DataSourceType.FILE, + doc_form: ChunkingMode.text, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + embedding_available: true, + app_count: 2, + document_count: 10, + total_document_count: 10, + word_count: 5000, + provider: 'vendor', + tags: [], + partial_member_list: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 2, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + built_in_field_enabled: false, + keyword_number: 10, + created_by: 'user-1', + updated_by: 'user-1', + updated_at: Date.now(), + runtime_mode: 'general', + enable_api: true, + is_multimodal: false, + ...overrides, +} as DataSet) + +let mockDataset: DataSet = createMockDataset() + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown, + ) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }), +})) + +// Import after mocks are registered +const { useFormState } = await import( + '@/app/components/datasets/settings/form/hooks/use-form-state', +) + +describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateDatasetSetting.mockResolvedValue({}) + mockDataset = createMockDataset() + }) + + describe('Form State Initialization from Dataset → Index Method → Retrieval Config Chain', () => { + it('should initialise all form dimensions from a QUALIFIED dataset', () => { + const { result } = renderHook(() => useFormState()) + + expect(result.current.name).toBe('Settings Test Dataset') + expect(result.current.description).toBe('Integration test dataset') + expect(result.current.indexMethod).toBe('high_quality') + expect(result.current.embeddingModel).toEqual({ + provider: 'openai', + model: 'text-embedding-ada-002', + }) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic) + }) + + it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => { + mockDataset = createMockDataset({ + indexing_technique: IndexingType.ECONOMICAL, + embedding_model: '', + embedding_model_provider: '', + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.keywordSearch, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + }) + + const { result } = renderHook(() => useFormState()) + + expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) + expect(result.current.embeddingModel).toEqual({ provider: '', model: '' }) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch) + }) + }) + + describe('Index Method Change → Retrieval Config Sync', () => { + it('should allow switching index method from QUALIFIED to ECONOMICAL', () => { + const { result } = renderHook(() => useFormState()) + + expect(result.current.indexMethod).toBe('high_quality') + + act(() => { + result.current.setIndexMethod(IndexingType.ECONOMICAL) + }) + + expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) + }) + + it('should allow updating retrieval config after index method switch', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setIndexMethod(IndexingType.ECONOMICAL) + }) + + act(() => { + result.current.setRetrievalConfig({ + ...result.current.retrievalConfig, + search_method: RETRIEVE_METHOD.keywordSearch, + reranking_enable: false, + }) + }) + + expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch) + expect(result.current.retrievalConfig.reranking_enable).toBe(false) + }) + + it('should preserve retrieval config when switching back to QUALIFIED', () => { + const { result } = renderHook(() => useFormState()) + + const originalConfig = { ...result.current.retrievalConfig } + + act(() => { + result.current.setIndexMethod(IndexingType.ECONOMICAL) + }) + act(() => { + result.current.setIndexMethod(IndexingType.QUALIFIED) + }) + + expect(result.current.indexMethod).toBe('high_quality') + expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method) + }) + }) + + describe('Permission Change → Member List Visibility Logic', () => { + it('should start with onlyMe permission and empty member selection', () => { + const { result } = renderHook(() => useFormState()) + + expect(result.current.permission).toBe(DatasetPermission.onlyMe) + expect(result.current.selectedMemberIDs).toEqual([]) + }) + + it('should enable member selection when switching to partialMembers', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + }) + + expect(result.current.permission).toBe(DatasetPermission.partialMembers) + expect(result.current.memberList).toHaveLength(3) + expect(result.current.memberList.map(m => m.id)).toEqual(['user-1', 'user-2', 'user-3']) + }) + + it('should persist member selection through permission toggle', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + result.current.setSelectedMemberIDs(['user-1', 'user-3']) + }) + + act(() => { + result.current.setPermission(DatasetPermission.allTeamMembers) + }) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + }) + + expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3']) + }) + + it('should include partial_member_list in save payload only for partialMembers', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + result.current.setSelectedMemberIDs(['user-2']) + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ + datasetId: 'ds-settings-1', + body: expect.objectContaining({ + permission: DatasetPermission.partialMembers, + partial_member_list: [ + expect.objectContaining({ user_id: 'user-2', role: 'admin' }), + ], + }), + }) + }) + + it('should not include partial_member_list for allTeamMembers permission', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.allTeamMembers) + }) + + await act(async () => { + await result.current.handleSave() + }) + + const savedBody = mockUpdateDatasetSetting.mock.calls[0][0].body as Record + expect(savedBody).not.toHaveProperty('partial_member_list') + }) + }) + + describe('Form Submission Validation → All Fields Together', () => { + it('should reject empty name on save', async () => { + const Toast = await import('@/app/components/base/toast') + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setName('') + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(Toast.default.notify).toHaveBeenCalledWith({ + type: 'error', + message: expect.any(String), + }) + expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() + }) + + it('should include all configuration dimensions in a successful save', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setName('Updated Name') + result.current.setDescription('Updated Description') + result.current.setIndexMethod(IndexingType.ECONOMICAL) + result.current.setKeywordNumber(15) + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ + datasetId: 'ds-settings-1', + body: expect.objectContaining({ + name: 'Updated Name', + description: 'Updated Description', + indexing_technique: 'economy', + keyword_number: 15, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + }), + }) + }) + + it('should call mutateDatasets and invalidDatasetList after successful save', async () => { + const { result } = renderHook(() => useFormState()) + + await act(async () => { + await result.current.handleSave() + }) + + await waitFor(() => { + expect(mockMutateDatasets).toHaveBeenCalled() + expect(mockInvalidDatasetList).toHaveBeenCalled() + }) + }) + }) + + describe('Embedding Model Change → Retrieval Config Cascade', () => { + it('should update embedding model independently of retrieval config', () => { + const { result } = renderHook(() => useFormState()) + + const originalRetrievalConfig = { ...result.current.retrievalConfig } + + act(() => { + result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' }) + }) + + expect(result.current.embeddingModel).toEqual({ + provider: 'cohere', + model: 'embed-english-v3.0', + }) + expect(result.current.retrievalConfig.search_method).toBe(originalRetrievalConfig.search_method) + }) + + it('should propagate embedding model into weighted retrieval config on save', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' }) + result.current.setRetrievalConfig({ + ...result.current.retrievalConfig, + search_method: RETRIEVE_METHOD.hybrid, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.6, + embedding_provider_name: '', + embedding_model_name: '', + }, + keyword_setting: { keyword_weight: 0.4 }, + }, + }) + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ + datasetId: 'ds-settings-1', + body: expect.objectContaining({ + embedding_model: 'embed-v3', + embedding_model_provider: 'cohere', + retrieval_model: expect.objectContaining({ + weights: expect.objectContaining({ + vector_setting: expect.objectContaining({ + embedding_provider_name: 'cohere', + embedding_model_name: 'embed-v3', + }), + }), + }), + }), + }) + }) + + it('should handle switching from semantic to hybrid search with embedding model', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setRetrievalConfig({ + ...result.current.retrievalConfig, + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'cohere', + reranking_model_name: 'rerank-english-v3.0', + }, + }) + }) + + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid) + expect(result.current.retrievalConfig.reranking_enable).toBe(true) + expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002') + }) + }) +}) diff --git a/web/__tests__/datasets/document-management.test.tsx b/web/__tests__/datasets/document-management.test.tsx new file mode 100644 index 0000000000..3b901ccee2 --- /dev/null +++ b/web/__tests__/datasets/document-management.test.tsx @@ -0,0 +1,335 @@ +/** + * Integration Test: Document Management Flow + * + * Tests cross-module interactions: query state (URL-based) → document list sorting → + * document selection → status filter utilities. + * Validates the data contract between documents page hooks and list component hooks. + */ + +import type { SimpleDocumentDetail } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' + +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams(''), + useRouter: () => ({ push: mockPush }), + usePathname: () => '/datasets/ds-1/documents', +})) + +const { sanitizeStatusValue, normalizeStatusForQuery } = await import( + '@/app/components/datasets/documents/status-filter', +) + +const { useDocumentSort } = await import( + '@/app/components/datasets/documents/components/document-list/hooks/use-document-sort', +) +const { useDocumentSelection } = await import( + '@/app/components/datasets/documents/components/document-list/hooks/use-document-selection', +) +const { default: useDocumentListQueryState } = await import( + '@/app/components/datasets/documents/hooks/use-document-list-query-state', +) + +type LocalDoc = SimpleDocumentDetail & { percent?: number } + +const createDoc = (overrides?: Partial): LocalDoc => ({ + id: `doc-${Math.random().toString(36).slice(2, 8)}`, + name: 'test-doc.txt', + word_count: 500, + hit_count: 10, + created_at: Date.now() / 1000, + data_source_type: DataSourceType.FILE, + display_status: 'available', + indexing_status: 'completed', + enabled: true, + archived: false, + doc_type: null, + doc_metadata: null, + position: 1, + dataset_process_rule_id: 'rule-1', + ...overrides, +} as LocalDoc) + +describe('Document Management Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Status Filter Utilities', () => { + it('should sanitize valid status values', () => { + expect(sanitizeStatusValue('all')).toBe('all') + expect(sanitizeStatusValue('available')).toBe('available') + expect(sanitizeStatusValue('error')).toBe('error') + }) + + it('should fallback to "all" for invalid values', () => { + expect(sanitizeStatusValue(null)).toBe('all') + expect(sanitizeStatusValue(undefined)).toBe('all') + expect(sanitizeStatusValue('')).toBe('all') + expect(sanitizeStatusValue('nonexistent')).toBe('all') + }) + + it('should handle URL aliases', () => { + // 'active' is aliased to 'available' + expect(sanitizeStatusValue('active')).toBe('available') + }) + + it('should normalize status for API query', () => { + expect(normalizeStatusForQuery('all')).toBe('all') + // 'enabled' normalized to 'available' for query + expect(normalizeStatusForQuery('enabled')).toBe('available') + }) + }) + + describe('URL-based Query State', () => { + it('should parse default query from empty URL params', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query).toEqual({ + page: 1, + limit: 10, + keyword: '', + status: 'all', + sort: '-created_at', + }) + }) + + it('should update query and push to router', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ keyword: 'test', page: 2 }) + }) + + expect(mockPush).toHaveBeenCalled() + // The push call should contain the updated query params + const pushUrl = mockPush.mock.calls[0][0] as string + expect(pushUrl).toContain('keyword=test') + expect(pushUrl).toContain('page=2') + }) + + it('should reset query to defaults', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.resetQuery() + }) + + expect(mockPush).toHaveBeenCalled() + // Default query omits default values from URL + const pushUrl = mockPush.mock.calls[0][0] as string + expect(pushUrl).toBe('/datasets/ds-1/documents') + }) + }) + + describe('Document Sort Integration', () => { + it('should return documents unsorted when no sort field set', () => { + const docs = [ + createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }), + createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }), + createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }), + ] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '-created_at', + })) + + expect(result.current.sortField).toBeNull() + expect(result.current.sortedDocuments).toHaveLength(3) + }) + + it('should sort by name descending', () => { + const docs = [ + createDoc({ id: 'doc-1', name: 'Banana.txt' }), + createDoc({ id: 'doc-2', name: 'Apple.txt' }), + createDoc({ id: 'doc-3', name: 'Cherry.txt' }), + ] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '-created_at', + })) + + act(() => { + result.current.handleSort('name') + }) + + expect(result.current.sortField).toBe('name') + expect(result.current.sortOrder).toBe('desc') + const names = result.current.sortedDocuments.map(d => d.name) + expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt']) + }) + + it('should toggle sort order on same field click', () => { + const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '-created_at', + })) + + act(() => result.current.handleSort('name')) + expect(result.current.sortOrder).toBe('desc') + + act(() => result.current.handleSort('name')) + expect(result.current.sortOrder).toBe('asc') + }) + + it('should filter by status before sorting', () => { + const docs = [ + createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }), + createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }), + createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }), + ] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: 'available', + remoteSortValue: '-created_at', + })) + + // Only 'available' documents should remain + expect(result.current.sortedDocuments).toHaveLength(2) + expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true) + }) + }) + + describe('Document Selection Integration', () => { + it('should manage selection state externally', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + createDoc({ id: 'doc-3' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: [], + onSelectedIdChange, + })) + + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isSomeSelected).toBe(false) + }) + + it('should select all documents', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: [], + onSelectedIdChange, + })) + + act(() => { + result.current.onSelectAll() + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith( + expect.arrayContaining(['doc-1', 'doc-2']), + ) + }) + + it('should detect all-selected state', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + ] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1', 'doc-2'], + onSelectedIdChange: vi.fn(), + })) + + expect(result.current.isAllSelected).toBe(true) + }) + + it('should detect partial selection', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + createDoc({ id: 'doc-3' }), + ] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1'], + onSelectedIdChange: vi.fn(), + })) + + expect(result.current.isSomeSelected).toBe(true) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should identify downloadable selected documents (FILE type only)', () => { + const docs = [ + createDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }), + createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }), + ] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1', 'doc-2'], + onSelectedIdChange: vi.fn(), + })) + + expect(result.current.downloadableSelectedIds).toEqual(['doc-1']) + }) + + it('should clear selection', () => { + const onSelectedIdChange = vi.fn() + const docs = [createDoc({ id: 'doc-1' })] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1'], + onSelectedIdChange, + })) + + act(() => { + result.current.clearSelection() + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith([]) + }) + }) + + describe('Cross-Module: Query State → Sort → Selection Pipeline', () => { + it('should maintain consistent default state across all hooks', () => { + const docs = [createDoc({ id: 'doc-1' })] + const { result: queryResult } = renderHook(() => useDocumentListQueryState()) + const { result: sortResult } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: queryResult.current.query.status, + remoteSortValue: queryResult.current.query.sort, + })) + const { result: selResult } = renderHook(() => useDocumentSelection({ + documents: sortResult.current.sortedDocuments, + selectedIds: [], + onSelectedIdChange: vi.fn(), + })) + + // Query defaults + expect(queryResult.current.query.sort).toBe('-created_at') + expect(queryResult.current.query.status).toBe('all') + + // Sort inherits 'all' status → no filtering applied + expect(sortResult.current.sortedDocuments).toHaveLength(1) + + // Selection starts empty + expect(selResult.current.isAllSelected).toBe(false) + }) + }) +}) diff --git a/web/__tests__/datasets/external-knowledge-base.test.tsx b/web/__tests__/datasets/external-knowledge-base.test.tsx new file mode 100644 index 0000000000..9c2b0da19d --- /dev/null +++ b/web/__tests__/datasets/external-knowledge-base.test.tsx @@ -0,0 +1,215 @@ +/** + * Integration Test: External Knowledge Base Creation Flow + * + * Tests the data contract, validation logic, and API interaction + * for external knowledge base creation. + */ + +import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations' +import { describe, expect, it } from 'vitest' + +// --- Factory --- +const createFormData = (overrides?: Partial): CreateKnowledgeBaseReq => ({ + name: 'My External KB', + description: 'A test external knowledge base', + external_knowledge_api_id: 'api-1', + external_knowledge_id: 'ext-kb-123', + external_retrieval_model: { + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + provider: 'external', + ...overrides, +}) + +describe('External Knowledge Base Creation Flow', () => { + describe('Data Contract: CreateKnowledgeBaseReq', () => { + it('should define a complete form structure', () => { + const form = createFormData() + + expect(form).toHaveProperty('name') + expect(form).toHaveProperty('external_knowledge_api_id') + expect(form).toHaveProperty('external_knowledge_id') + expect(form).toHaveProperty('external_retrieval_model') + expect(form).toHaveProperty('provider') + expect(form.provider).toBe('external') + }) + + it('should include retrieval model settings', () => { + const form = createFormData() + + expect(form.external_retrieval_model).toEqual({ + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }) + }) + + it('should allow partial overrides', () => { + const form = createFormData({ + name: 'Custom Name', + external_retrieval_model: { + top_k: 10, + score_threshold: 0.8, + score_threshold_enabled: true, + }, + }) + + expect(form.name).toBe('Custom Name') + expect(form.external_retrieval_model.top_k).toBe(10) + expect(form.external_retrieval_model.score_threshold_enabled).toBe(true) + }) + }) + + describe('Form Validation Logic', () => { + const isFormValid = (form: CreateKnowledgeBaseReq): boolean => { + return ( + form.name.trim() !== '' + && form.external_knowledge_api_id !== '' + && form.external_knowledge_id !== '' + && form.external_retrieval_model.top_k !== undefined + && form.external_retrieval_model.score_threshold !== undefined + ) + } + + it('should validate a complete form', () => { + const form = createFormData() + expect(isFormValid(form)).toBe(true) + }) + + it('should reject empty name', () => { + const form = createFormData({ name: '' }) + expect(isFormValid(form)).toBe(false) + }) + + it('should reject whitespace-only name', () => { + const form = createFormData({ name: ' ' }) + expect(isFormValid(form)).toBe(false) + }) + + it('should reject empty external_knowledge_api_id', () => { + const form = createFormData({ external_knowledge_api_id: '' }) + expect(isFormValid(form)).toBe(false) + }) + + it('should reject empty external_knowledge_id', () => { + const form = createFormData({ external_knowledge_id: '' }) + expect(isFormValid(form)).toBe(false) + }) + }) + + describe('Form State Transitions', () => { + it('should start with empty default state', () => { + const defaultForm: CreateKnowledgeBaseReq = { + name: '', + description: '', + external_knowledge_api_id: '', + external_knowledge_id: '', + external_retrieval_model: { + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + provider: 'external', + } + + // Verify default state matches component's initial useState + expect(defaultForm.name).toBe('') + expect(defaultForm.external_knowledge_api_id).toBe('') + expect(defaultForm.external_knowledge_id).toBe('') + expect(defaultForm.provider).toBe('external') + }) + + it('should support immutable form updates', () => { + const form = createFormData({ name: '' }) + const updated = { ...form, name: 'Updated Name' } + + expect(form.name).toBe('') + expect(updated.name).toBe('Updated Name') + // Other fields should remain unchanged + expect(updated.external_knowledge_api_id).toBe(form.external_knowledge_api_id) + }) + + it('should support retrieval model updates', () => { + const form = createFormData() + const updated = { + ...form, + external_retrieval_model: { + ...form.external_retrieval_model, + top_k: 10, + score_threshold_enabled: true, + }, + } + + expect(updated.external_retrieval_model.top_k).toBe(10) + expect(updated.external_retrieval_model.score_threshold_enabled).toBe(true) + // Unchanged field + expect(updated.external_retrieval_model.score_threshold).toBe(0.5) + }) + }) + + describe('API Call Data Contract', () => { + it('should produce a valid API payload from form data', () => { + const form = createFormData() + + // The API expects the full CreateKnowledgeBaseReq + expect(form.name).toBeTruthy() + expect(form.external_knowledge_api_id).toBeTruthy() + expect(form.external_knowledge_id).toBeTruthy() + expect(form.provider).toBe('external') + expect(typeof form.external_retrieval_model.top_k).toBe('number') + expect(typeof form.external_retrieval_model.score_threshold).toBe('number') + expect(typeof form.external_retrieval_model.score_threshold_enabled).toBe('boolean') + }) + + it('should support optional description', () => { + const formWithDesc = createFormData({ description: 'Some description' }) + const formWithoutDesc = createFormData({ description: '' }) + + expect(formWithDesc.description).toBe('Some description') + expect(formWithoutDesc.description).toBe('') + }) + + it('should validate retrieval model bounds', () => { + const form = createFormData({ + external_retrieval_model: { + top_k: 0, + score_threshold: 0, + score_threshold_enabled: false, + }, + }) + + expect(form.external_retrieval_model.top_k).toBe(0) + expect(form.external_retrieval_model.score_threshold).toBe(0) + }) + }) + + describe('External API List Integration', () => { + it('should validate API item structure', () => { + const apiItem = { + id: 'api-1', + name: 'Production API', + settings: { + endpoint: 'https://api.example.com', + api_key: 'key-123', + }, + } + + expect(apiItem).toHaveProperty('id') + expect(apiItem).toHaveProperty('name') + expect(apiItem).toHaveProperty('settings') + expect(apiItem.settings).toHaveProperty('endpoint') + expect(apiItem.settings).toHaveProperty('api_key') + }) + + it('should link API selection to form data', () => { + const selectedApi = { id: 'api-2', name: 'Staging API' } + const form = createFormData({ + external_knowledge_api_id: selectedApi.id, + }) + + expect(form.external_knowledge_api_id).toBe('api-2') + }) + }) +}) diff --git a/web/__tests__/datasets/hit-testing-flow.test.tsx b/web/__tests__/datasets/hit-testing-flow.test.tsx new file mode 100644 index 0000000000..93d6f77d8f --- /dev/null +++ b/web/__tests__/datasets/hit-testing-flow.test.tsx @@ -0,0 +1,404 @@ +/** + * Integration Test: Hit Testing Flow + * + * Tests the query submission → API response → callback chain flow + * by rendering the actual QueryInput component and triggering user interactions. + * Validates that the production onSubmit logic correctly constructs payloads + * and invokes callbacks on success/failure. + */ + +import type { + HitTestingResponse, + Query, +} from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import QueryInput from '@/app/components/datasets/hit-testing/components/query-input' +import { RETRIEVE_METHOD } from '@/types/app' + +// --- Mocks --- + +vi.mock('@/context/dataset-detail', () => ({ + default: {}, + useDatasetDetailContext: vi.fn(() => ({ dataset: undefined })), + useDatasetDetailContextWithSelector: vi.fn(() => false), +})) + +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(() => ({})), + useContextSelector: vi.fn(() => false), + createContext: vi.fn(() => ({})), +})) + +vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ + default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => ( +
+ {textArea} + {actionButton} +
+ ), +})) + +// --- Factories --- + +const createRetrievalConfig = (overrides = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, +} as RetrievalConfig) + +const createHitTestingResponse = (numResults: number): HitTestingResponse => ({ + query: { + content: 'What is Dify?', + tsne_position: { x: 0, y: 0 }, + }, + records: Array.from({ length: numResults }, (_, i) => ({ + segment: { + id: `seg-${i}`, + document: { + id: `doc-${i}`, + data_source_type: 'upload_file', + name: `document-${i}.txt`, + doc_type: null as unknown as import('@/models/datasets').DocType, + }, + content: `Result content ${i}`, + sign_content: `Result content ${i}`, + position: i + 1, + word_count: 100 + i * 50, + tokens: 50 + i * 25, + keywords: ['test', 'dify'], + hit_count: i * 5, + index_node_hash: `hash-${i}`, + answer: '', + }, + content: { + id: `seg-${i}`, + document: { + id: `doc-${i}`, + data_source_type: 'upload_file', + name: `document-${i}.txt`, + doc_type: null as unknown as import('@/models/datasets').DocType, + }, + content: `Result content ${i}`, + sign_content: `Result content ${i}`, + position: i + 1, + word_count: 100 + i * 50, + tokens: 50 + i * 25, + keywords: ['test', 'dify'], + hit_count: i * 5, + index_node_hash: `hash-${i}`, + answer: '', + }, + score: 0.95 - i * 0.1, + tsne_position: { x: 0, y: 0 }, + child_chunks: null, + files: [], + })), +}) + +const createTextQuery = (content: string): Query[] => [ + { content, content_type: 'text_query', file_info: null }, +] + +// --- Helpers --- + +const findSubmitButton = () => { + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + expect(submitButton).toBeTruthy() + return submitButton! +} + +// --- Tests --- + +describe('Hit Testing Flow', () => { + const mockHitTestingMutation = vi.fn() + const mockExternalMutation = vi.fn() + const mockSetHitResult = vi.fn() + const mockSetExternalHitResult = vi.fn() + const mockOnUpdateList = vi.fn() + const mockSetQueries = vi.fn() + const mockOnClickRetrievalMethod = vi.fn() + const mockOnSubmit = vi.fn() + + const createDefaultProps = (overrides: Record = {}) => ({ + onUpdateList: mockOnUpdateList, + setHitResult: mockSetHitResult, + setExternalHitResult: mockSetExternalHitResult, + loading: false, + queries: [] as Query[], + setQueries: mockSetQueries, + isExternal: false, + onClickRetrievalMethod: mockOnClickRetrievalMethod, + retrievalConfig: createRetrievalConfig(), + isEconomy: false, + onSubmit: mockOnSubmit, + hitTestingMutation: mockHitTestingMutation, + externalKnowledgeBaseHitTestingMutation: mockExternalMutation, + ...overrides, + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Query Submission → API Call', () => { + it('should call hitTestingMutation with correct payload including retrieval model', async () => { + const retrievalConfig = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + top_k: 3, + score_threshold_enabled: false, + }) + mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3)) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockHitTestingMutation).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'How does RAG work?', + attachment_ids: [], + retrieval_model: expect.objectContaining({ + search_method: RETRIEVE_METHOD.semantic, + top_k: 3, + score_threshold_enabled: false, + }), + }), + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + }) + }) + + it('should override search_method to keywordSearch when isEconomy is true', async () => { + const retrievalConfig = createRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }) + mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1)) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockHitTestingMutation).toHaveBeenCalledWith( + expect.objectContaining({ + retrieval_model: expect.objectContaining({ + search_method: RETRIEVE_METHOD.keywordSearch, + }), + }), + expect.anything(), + ) + }) + }) + + it('should handle empty results by calling setHitResult with empty records', async () => { + const emptyResponse = createHitTestingResponse(0) + mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(emptyResponse) + return emptyResponse + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockSetHitResult).toHaveBeenCalledWith( + expect.objectContaining({ records: [] }), + ) + }) + }) + + it('should not call success callbacks when mutation resolves without onSuccess', async () => { + // Simulate a mutation that resolves but does not invoke the onSuccess callback + mockHitTestingMutation.mockResolvedValue(undefined) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockHitTestingMutation).toHaveBeenCalled() + }) + // Success callbacks should not fire when onSuccess is not invoked + expect(mockSetHitResult).not.toHaveBeenCalled() + expect(mockOnUpdateList).not.toHaveBeenCalled() + expect(mockOnSubmit).not.toHaveBeenCalled() + }) + }) + + describe('API Response → Results Data Contract', () => { + it('should produce results with required segment fields for rendering', () => { + const response = createHitTestingResponse(3) + + // Validate each result has the fields needed by ResultItem component + response.records.forEach((record) => { + expect(record.segment).toHaveProperty('id') + expect(record.segment).toHaveProperty('content') + expect(record.segment).toHaveProperty('position') + expect(record.segment).toHaveProperty('word_count') + expect(record.segment).toHaveProperty('document') + expect(record.segment.document).toHaveProperty('name') + expect(record.score).toBeGreaterThanOrEqual(0) + expect(record.score).toBeLessThanOrEqual(1) + }) + }) + + it('should maintain correct score ordering', () => { + const response = createHitTestingResponse(5) + + for (let i = 1; i < response.records.length; i++) { + expect(response.records[i - 1].score).toBeGreaterThanOrEqual(response.records[i].score) + } + }) + + it('should include document metadata for result item display', () => { + const response = createHitTestingResponse(1) + const record = response.records[0] + + expect(record.segment.document.name).toBeTruthy() + expect(record.segment.document.data_source_type).toBeTruthy() + }) + }) + + describe('Successful Submission → Callback Chain', () => { + it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => { + const response = createHitTestingResponse(3) + mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(response) + return response + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockSetHitResult).toHaveBeenCalledWith(response) + expect(mockOnUpdateList).toHaveBeenCalledTimes(1) + expect(mockOnSubmit).toHaveBeenCalledTimes(1) + }) + }) + + it('should trigger records list refresh via onUpdateList after query', async () => { + const response = createHitTestingResponse(1) + mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(response) + return response + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockOnUpdateList).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('External KB Hit Testing', () => { + it('should use external mutation with correct payload for external datasets', async () => { + mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => { + const response = { records: [] } + options?.onSuccess?.(response) + return response + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockExternalMutation).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'test', + external_retrieval_model: expect.objectContaining({ + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }), + }), + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + // Internal mutation should NOT be called + expect(mockHitTestingMutation).not.toHaveBeenCalled() + }) + }) + + it('should call setExternalHitResult and onUpdateList on successful external submission', async () => { + const externalResponse = { records: [] } + mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => { + options?.onSuccess?.(externalResponse) + return externalResponse + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse) + expect(mockOnUpdateList).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/web/__tests__/datasets/metadata-management-flow.test.tsx b/web/__tests__/datasets/metadata-management-flow.test.tsx new file mode 100644 index 0000000000..d8403f0f21 --- /dev/null +++ b/web/__tests__/datasets/metadata-management-flow.test.tsx @@ -0,0 +1,337 @@ +/** + * Integration Test: Metadata Management Flow + * + * Tests the cross-module composition of metadata name validation, type constraints, + * and duplicate detection across the metadata management hooks. + * + * The unit-level use-check-metadata-name.spec.ts tests the validation hook alone. + * This integration test verifies: + * - Name validation combined with existing metadata list (duplicate detection) + * - Metadata type enum constraints matching expected data model + * - Full add/rename workflow: validate name → check duplicates → allow or reject + * - Name uniqueness logic: existing metadata keeps its own name, cannot take another's + */ + +import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types' +import { renderHook } from '@testing-library/react' +import { DataType } from '@/app/components/datasets/metadata/types' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const { default: useCheckMetadataName } = await import( + '@/app/components/datasets/metadata/hooks/use-check-metadata-name', +) + +// --- Factory functions --- + +const createMetadataItem = ( + id: string, + name: string, + type = DataType.string, + count = 0, +): MetadataItemWithValueLength => ({ + id, + name, + type, + count, +}) + +const createMetadataList = (): MetadataItemWithValueLength[] => [ + createMetadataItem('meta-1', 'author', DataType.string, 5), + createMetadataItem('meta-2', 'created_date', DataType.time, 10), + createMetadataItem('meta-3', 'page_count', DataType.number, 3), + createMetadataItem('meta-4', 'source_url', DataType.string, 8), + createMetadataItem('meta-5', 'version', DataType.number, 2), +] + +describe('Metadata Management Flow - Cross-Module Validation Composition', () => { + describe('Name Validation Flow: Format Rules', () => { + it('should accept valid lowercase names with underscores', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + expect(result.current.checkName('valid_name').errorMsg).toBe('') + expect(result.current.checkName('author').errorMsg).toBe('') + expect(result.current.checkName('page_count').errorMsg).toBe('') + expect(result.current.checkName('v2_field').errorMsg).toBe('') + }) + + it('should reject empty names', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + expect(result.current.checkName('').errorMsg).toBeTruthy() + }) + + it('should reject names with invalid characters', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + expect(result.current.checkName('Author').errorMsg).toBeTruthy() + expect(result.current.checkName('my-field').errorMsg).toBeTruthy() + expect(result.current.checkName('field name').errorMsg).toBeTruthy() + expect(result.current.checkName('1field').errorMsg).toBeTruthy() + expect(result.current.checkName('_private').errorMsg).toBeTruthy() + }) + + it('should reject names exceeding 255 characters', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + const longName = 'a'.repeat(256) + expect(result.current.checkName(longName).errorMsg).toBeTruthy() + + const maxName = 'a'.repeat(255) + expect(result.current.checkName(maxName).errorMsg).toBe('') + }) + }) + + describe('Metadata Type Constraints: Enum Values Match Expected Set', () => { + it('should define exactly three data types', () => { + const typeValues = Object.values(DataType) + expect(typeValues).toHaveLength(3) + }) + + it('should include string, number, and time types', () => { + expect(DataType.string).toBe('string') + expect(DataType.number).toBe('number') + expect(DataType.time).toBe('time') + }) + + it('should use consistent types in metadata items', () => { + const metadataList = createMetadataList() + + const stringItems = metadataList.filter(m => m.type === DataType.string) + const numberItems = metadataList.filter(m => m.type === DataType.number) + const timeItems = metadataList.filter(m => m.type === DataType.time) + + expect(stringItems).toHaveLength(2) + expect(numberItems).toHaveLength(2) + expect(timeItems).toHaveLength(1) + }) + + it('should enforce type-safe metadata item construction', () => { + const item = createMetadataItem('test-1', 'test_field', DataType.number, 0) + + expect(item.id).toBe('test-1') + expect(item.name).toBe('test_field') + expect(item.type).toBe(DataType.number) + expect(item.count).toBe(0) + }) + }) + + describe('Duplicate Name Detection: Add Metadata → Check Name → Detect Duplicates', () => { + it('should detect duplicate names against an existing metadata list', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const checkDuplicate = (newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return existingMetadata.some(m => m.name === newName) + } + + expect(checkDuplicate('author')).toBe(true) + expect(checkDuplicate('created_date')).toBe(true) + expect(checkDuplicate('page_count')).toBe(true) + }) + + it('should allow names that do not conflict with existing metadata', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isNameAvailable = (newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName) + } + + expect(isNameAvailable('category')).toBe(true) + expect(isNameAvailable('file_size')).toBe(true) + expect(isNameAvailable('language')).toBe(true) + }) + + it('should reject names that fail format validation before duplicate check', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + const validateAndCheckDuplicate = (newName: string): { valid: boolean, reason: string } => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return { valid: false, reason: 'format' } + return { valid: true, reason: '' } + } + + expect(validateAndCheckDuplicate('Author').reason).toBe('format') + expect(validateAndCheckDuplicate('').reason).toBe('format') + expect(validateAndCheckDuplicate('valid_name').valid).toBe(true) + }) + }) + + describe('Name Uniqueness Across Edits: Rename Workflow', () => { + it('should allow an existing metadata item to keep its own name', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + // Allow keeping the same name (skip self in duplicate check) + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + // Author keeping its own name should be valid + expect(isRenameValid('meta-1', 'author')).toBe(true) + // page_count keeping its own name should be valid + expect(isRenameValid('meta-3', 'page_count')).toBe(true) + }) + + it('should reject renaming to another existing metadata name', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + // Author trying to rename to "page_count" (taken by meta-3) + expect(isRenameValid('meta-1', 'page_count')).toBe(false) + // version trying to rename to "source_url" (taken by meta-4) + expect(isRenameValid('meta-5', 'source_url')).toBe(false) + }) + + it('should allow renaming to a completely new valid name', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + expect(isRenameValid('meta-1', 'document_author')).toBe(true) + expect(isRenameValid('meta-2', 'publish_date')).toBe(true) + expect(isRenameValid('meta-3', 'total_pages')).toBe(true) + }) + + it('should reject renaming with an invalid format even if name is unique', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + expect(isRenameValid('meta-1', 'New Author')).toBe(false) + expect(isRenameValid('meta-2', '2024_date')).toBe(false) + expect(isRenameValid('meta-3', '')).toBe(false) + }) + }) + + describe('Full Metadata Management Workflow', () => { + it('should support a complete add-validate-check-duplicate cycle', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const addMetadataField = ( + name: string, + type: DataType, + ): { success: boolean, error?: string } => { + const formatCheck = result.current.checkName(name) + if (formatCheck.errorMsg) + return { success: false, error: 'invalid_format' } + + if (existingMetadata.some(m => m.name === name)) + return { success: false, error: 'duplicate_name' } + + existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type)) + return { success: true } + } + + // Add a valid new field + const result1 = addMetadataField('department', DataType.string) + expect(result1.success).toBe(true) + expect(existingMetadata).toHaveLength(6) + + // Try to add a duplicate + const result2 = addMetadataField('author', DataType.string) + expect(result2.success).toBe(false) + expect(result2.error).toBe('duplicate_name') + expect(existingMetadata).toHaveLength(6) + + // Try to add an invalid name + const result3 = addMetadataField('Invalid Name', DataType.string) + expect(result3.success).toBe(false) + expect(result3.error).toBe('invalid_format') + expect(existingMetadata).toHaveLength(6) + + // Add another valid field + const result4 = addMetadataField('priority_level', DataType.number) + expect(result4.success).toBe(true) + expect(existingMetadata).toHaveLength(7) + }) + + it('should support a complete rename workflow with validation chain', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const renameMetadataField = ( + itemId: string, + newName: string, + ): { success: boolean, error?: string } => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return { success: false, error: 'invalid_format' } + + if (existingMetadata.some(m => m.name === newName && m.id !== itemId)) + return { success: false, error: 'duplicate_name' } + + const item = existingMetadata.find(m => m.id === itemId) + if (!item) + return { success: false, error: 'not_found' } + + // Simulate the rename in-place + const index = existingMetadata.indexOf(item) + existingMetadata[index] = { ...item, name: newName } + return { success: true } + } + + // Rename author to document_author + expect(renameMetadataField('meta-1', 'document_author').success).toBe(true) + expect(existingMetadata.find(m => m.id === 'meta-1')?.name).toBe('document_author') + + // Try renaming created_date to page_count (already taken) + expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name') + + // Rename to invalid format + expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format') + + // Rename non-existent item + expect(renameMetadataField('meta-999', 'something').error).toBe('not_found') + }) + + it('should maintain validation consistency across multiple operations', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + // Validate the same name multiple times for consistency + const name = 'consistent_field' + const results = Array.from({ length: 5 }, () => result.current.checkName(name)) + + expect(results.every(r => r.errorMsg === '')).toBe(true) + + // Validate an invalid name multiple times + const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid')) + expect(invalidResults.every(r => r.errorMsg !== '')).toBe(true) + }) + }) +}) diff --git a/web/__tests__/datasets/pipeline-datasource-flow.test.tsx b/web/__tests__/datasets/pipeline-datasource-flow.test.tsx new file mode 100644 index 0000000000..dc140e8514 --- /dev/null +++ b/web/__tests__/datasets/pipeline-datasource-flow.test.tsx @@ -0,0 +1,477 @@ +/** + * Integration Test: Pipeline Data Source Store Composition + * + * Tests cross-slice interactions in the pipeline data source Zustand store. + * The unit-level slice specs test each slice in isolation. + * This integration test verifies: + * - Store initialization produces correct defaults across all slices + * - Cross-slice coordination (e.g. credential shared across slices) + * - State isolation: changes in one slice do not affect others + * - Full workflow simulation through credential → source → data path + */ + +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem, FileItem } from '@/models/datasets' +import type { OnlineDriveFile } from '@/models/pipeline' +import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' +import { CrawlStep } from '@/models/datasets' +import { OnlineDriveFileType } from '@/models/pipeline' + +// --- Factory functions --- + +const createFileItem = (id: string): FileItem => ({ + fileID: id, + file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'], + progress: 100, +}) + +const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({ + title: title ?? `Page: ${url}`, + markdown: `# ${title ?? url}\n\nContent for ${url}`, + description: `Description for ${url}`, + source_url: url, +}) + +const createOnlineDriveFile = (id: string, name: string, type = OnlineDriveFileType.file): OnlineDriveFile => ({ + id, + name, + size: 2048, + type, +}) + +const createNotionPage = (pageId: string): NotionPage => ({ + page_id: pageId, + page_name: `Page ${pageId}`, + page_icon: null, + is_bound: true, + parent_id: 'parent-1', + type: 'page', + workspace_id: 'ws-1', +}) + +describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => { + describe('Store Initialization → All Slices Have Correct Defaults', () => { + it('should create a store with all five slices combined', () => { + const store = createDataSourceStore() + const state = store.getState() + + // Common slice defaults + expect(state.currentCredentialId).toBe('') + expect(state.currentNodeIdRef.current).toBe('') + + // Local file slice defaults + expect(state.localFileList).toEqual([]) + expect(state.currentLocalFile).toBeUndefined() + + // Online document slice defaults + expect(state.documentsData).toEqual([]) + expect(state.onlineDocuments).toEqual([]) + expect(state.searchValue).toBe('') + expect(state.selectedPagesId).toEqual(new Set()) + + // Website crawl slice defaults + expect(state.websitePages).toEqual([]) + expect(state.step).toBe(CrawlStep.init) + expect(state.previewIndex).toBe(-1) + + // Online drive slice defaults + expect(state.breadcrumbs).toEqual([]) + expect(state.prefix).toEqual([]) + expect(state.keywords).toBe('') + expect(state.selectedFileIds).toEqual([]) + expect(state.onlineDriveFileList).toEqual([]) + expect(state.bucket).toBe('') + expect(state.hasBucket).toBe(false) + }) + }) + + describe('Cross-Slice Coordination: Shared Credential', () => { + it('should set credential that is accessible from the common slice', () => { + const store = createDataSourceStore() + + store.getState().setCurrentCredentialId('cred-abc-123') + + expect(store.getState().currentCredentialId).toBe('cred-abc-123') + }) + + it('should allow credential update independently of all other slices', () => { + const store = createDataSourceStore() + + store.getState().setLocalFileList([createFileItem('f1')]) + store.getState().setCurrentCredentialId('cred-xyz') + + expect(store.getState().currentCredentialId).toBe('cred-xyz') + expect(store.getState().localFileList).toHaveLength(1) + }) + }) + + describe('Local File Workflow: Set Files → Verify List → Clear', () => { + it('should set and retrieve local file list', () => { + const store = createDataSourceStore() + const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')] + + store.getState().setLocalFileList(files) + + expect(store.getState().localFileList).toHaveLength(3) + expect(store.getState().localFileList[0].fileID).toBe('f1') + expect(store.getState().localFileList[2].fileID).toBe('f3') + }) + + it('should update preview ref when setting file list', () => { + const store = createDataSourceStore() + const files = [createFileItem('f-preview')] + + store.getState().setLocalFileList(files) + + expect(store.getState().previewLocalFileRef.current).toBeDefined() + }) + + it('should clear files by setting empty list', () => { + const store = createDataSourceStore() + + store.getState().setLocalFileList([createFileItem('f1')]) + expect(store.getState().localFileList).toHaveLength(1) + + store.getState().setLocalFileList([]) + expect(store.getState().localFileList).toHaveLength(0) + }) + + it('should set and clear current local file selection', () => { + const store = createDataSourceStore() + const file = { id: 'current-file', name: 'current.txt' } as FileItem['file'] + + store.getState().setCurrentLocalFile(file) + expect(store.getState().currentLocalFile).toBeDefined() + expect(store.getState().currentLocalFile?.id).toBe('current-file') + + store.getState().setCurrentLocalFile(undefined) + expect(store.getState().currentLocalFile).toBeUndefined() + }) + }) + + describe('Online Document Workflow: Set Documents → Select Pages → Verify', () => { + it('should set documents data and online documents', () => { + const store = createDataSourceStore() + const pages = [createNotionPage('page-1'), createNotionPage('page-2')] + + store.getState().setOnlineDocuments(pages) + + expect(store.getState().onlineDocuments).toHaveLength(2) + expect(store.getState().onlineDocuments[0].page_id).toBe('page-1') + }) + + it('should update preview ref when setting online documents', () => { + const store = createDataSourceStore() + const pages = [createNotionPage('page-preview')] + + store.getState().setOnlineDocuments(pages) + + expect(store.getState().previewOnlineDocumentRef.current).toBeDefined() + expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview') + }) + + it('should track selected page IDs', () => { + const store = createDataSourceStore() + const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')] + + store.getState().setOnlineDocuments(pages) + store.getState().setSelectedPagesId(new Set(['p1', 'p3'])) + + expect(store.getState().selectedPagesId.size).toBe(2) + expect(store.getState().selectedPagesId.has('p1')).toBe(true) + expect(store.getState().selectedPagesId.has('p2')).toBe(false) + expect(store.getState().selectedPagesId.has('p3')).toBe(true) + }) + + it('should manage search value for filtering documents', () => { + const store = createDataSourceStore() + + store.getState().setSearchValue('meeting notes') + + expect(store.getState().searchValue).toBe('meeting notes') + }) + + it('should set and clear current document selection', () => { + const store = createDataSourceStore() + const page = createNotionPage('current-page') + + store.getState().setCurrentDocument(page) + expect(store.getState().currentDocument?.page_id).toBe('current-page') + + store.getState().setCurrentDocument(undefined) + expect(store.getState().currentDocument).toBeUndefined() + }) + }) + + describe('Website Crawl Workflow: Set Pages → Track Step → Preview', () => { + it('should set website pages and update preview ref', () => { + const store = createDataSourceStore() + const pages = [ + createCrawlResultItem('https://example.com'), + createCrawlResultItem('https://example.com/about'), + ] + + store.getState().setWebsitePages(pages) + + expect(store.getState().websitePages).toHaveLength(2) + expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com') + }) + + it('should manage crawl step transitions', () => { + const store = createDataSourceStore() + + expect(store.getState().step).toBe(CrawlStep.init) + + store.getState().setStep(CrawlStep.running) + expect(store.getState().step).toBe(CrawlStep.running) + + store.getState().setStep(CrawlStep.finished) + expect(store.getState().step).toBe(CrawlStep.finished) + }) + + it('should set crawl result with data and timing', () => { + const store = createDataSourceStore() + const result = { + data: [createCrawlResultItem('https://test.com')], + time_consuming: 3.5, + } + + store.getState().setCrawlResult(result) + + expect(store.getState().crawlResult?.data).toHaveLength(1) + expect(store.getState().crawlResult?.time_consuming).toBe(3.5) + }) + + it('should manage preview index for page navigation', () => { + const store = createDataSourceStore() + + store.getState().setPreviewIndex(2) + expect(store.getState().previewIndex).toBe(2) + + store.getState().setPreviewIndex(-1) + expect(store.getState().previewIndex).toBe(-1) + }) + + it('should set and clear current website selection', () => { + const store = createDataSourceStore() + const page = createCrawlResultItem('https://current.com') + + store.getState().setCurrentWebsite(page) + expect(store.getState().currentWebsite?.source_url).toBe('https://current.com') + + store.getState().setCurrentWebsite(undefined) + expect(store.getState().currentWebsite).toBeUndefined() + }) + }) + + describe('Online Drive Workflow: Breadcrumbs → File Selection → Navigation', () => { + it('should manage breadcrumb navigation', () => { + const store = createDataSourceStore() + + store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder']) + + expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder']) + }) + + it('should support breadcrumb push/pop pattern', () => { + const store = createDataSourceStore() + + store.getState().setBreadcrumbs(['root']) + store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1']) + store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2']) + + expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2']) + + // Pop back one level + store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1)) + expect(store.getState().breadcrumbs).toEqual(['root', 'level-1']) + }) + + it('should manage file list and selection', () => { + const store = createDataSourceStore() + const files = [ + createOnlineDriveFile('drive-1', 'report.pdf'), + createOnlineDriveFile('drive-2', 'data.csv'), + createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder), + ] + + store.getState().setOnlineDriveFileList(files) + expect(store.getState().onlineDriveFileList).toHaveLength(3) + + store.getState().setSelectedFileIds(['drive-1', 'drive-2']) + expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2']) + }) + + it('should update preview ref when selecting files', () => { + const store = createDataSourceStore() + const files = [ + createOnlineDriveFile('drive-a', 'file-a.txt'), + createOnlineDriveFile('drive-b', 'file-b.txt'), + ] + + store.getState().setOnlineDriveFileList(files) + store.getState().setSelectedFileIds(['drive-b']) + + expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b') + }) + + it('should manage bucket and prefix for S3-like navigation', () => { + const store = createDataSourceStore() + + store.getState().setBucket('my-data-bucket') + store.getState().setPrefix(['data', '2024']) + store.getState().setHasBucket(true) + + expect(store.getState().bucket).toBe('my-data-bucket') + expect(store.getState().prefix).toEqual(['data', '2024']) + expect(store.getState().hasBucket).toBe(true) + }) + + it('should manage keywords for search filtering', () => { + const store = createDataSourceStore() + + store.getState().setKeywords('quarterly report') + expect(store.getState().keywords).toBe('quarterly report') + }) + }) + + describe('State Isolation: Changes to One Slice Do Not Affect Others', () => { + it('should keep local file state independent from online document state', () => { + const store = createDataSourceStore() + + store.getState().setLocalFileList([createFileItem('local-1')]) + store.getState().setOnlineDocuments([createNotionPage('notion-1')]) + + expect(store.getState().localFileList).toHaveLength(1) + expect(store.getState().onlineDocuments).toHaveLength(1) + + // Clearing local files should not affect online documents + store.getState().setLocalFileList([]) + expect(store.getState().localFileList).toHaveLength(0) + expect(store.getState().onlineDocuments).toHaveLength(1) + }) + + it('should keep website crawl state independent from online drive state', () => { + const store = createDataSourceStore() + + store.getState().setWebsitePages([createCrawlResultItem('https://site.com')]) + store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')]) + + expect(store.getState().websitePages).toHaveLength(1) + expect(store.getState().onlineDriveFileList).toHaveLength(1) + + // Clearing website pages should not affect drive files + store.getState().setWebsitePages([]) + expect(store.getState().websitePages).toHaveLength(0) + expect(store.getState().onlineDriveFileList).toHaveLength(1) + }) + + it('should create fully independent store instances', () => { + const storeA = createDataSourceStore() + const storeB = createDataSourceStore() + + storeA.getState().setCurrentCredentialId('cred-A') + storeA.getState().setLocalFileList([createFileItem('fa-1')]) + + expect(storeA.getState().currentCredentialId).toBe('cred-A') + expect(storeB.getState().currentCredentialId).toBe('') + expect(storeB.getState().localFileList).toEqual([]) + }) + }) + + describe('Full Workflow Simulation: Credential → Source → Data → Verify', () => { + it('should support a complete local file upload workflow', () => { + const store = createDataSourceStore() + + // Step 1: Set credential + store.getState().setCurrentCredentialId('upload-cred-1') + + // Step 2: Set file list + const files = [createFileItem('upload-1'), createFileItem('upload-2')] + store.getState().setLocalFileList(files) + + // Step 3: Select current file for preview + store.getState().setCurrentLocalFile(files[0].file) + + // Verify all state is consistent + expect(store.getState().currentCredentialId).toBe('upload-cred-1') + expect(store.getState().localFileList).toHaveLength(2) + expect(store.getState().currentLocalFile?.id).toBe('upload-1') + expect(store.getState().previewLocalFileRef.current).toBeDefined() + }) + + it('should support a complete website crawl workflow', () => { + const store = createDataSourceStore() + + // Step 1: Set credential + store.getState().setCurrentCredentialId('crawl-cred-1') + + // Step 2: Init crawl + store.getState().setStep(CrawlStep.running) + + // Step 3: Crawl completes with results + const crawledPages = [ + createCrawlResultItem('https://docs.example.com/guide'), + createCrawlResultItem('https://docs.example.com/api'), + createCrawlResultItem('https://docs.example.com/faq'), + ] + store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 }) + store.getState().setStep(CrawlStep.finished) + + // Step 4: Set website pages from results + store.getState().setWebsitePages(crawledPages) + + // Step 5: Set preview + store.getState().setPreviewIndex(1) + + // Verify all state + expect(store.getState().currentCredentialId).toBe('crawl-cred-1') + expect(store.getState().step).toBe(CrawlStep.finished) + expect(store.getState().websitePages).toHaveLength(3) + expect(store.getState().crawlResult?.time_consuming).toBe(12.5) + expect(store.getState().previewIndex).toBe(1) + expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://docs.example.com/guide') + }) + + it('should support a complete online drive navigation workflow', () => { + const store = createDataSourceStore() + + // Step 1: Set credential + store.getState().setCurrentCredentialId('drive-cred-1') + + // Step 2: Set bucket + store.getState().setBucket('company-docs') + store.getState().setHasBucket(true) + + // Step 3: Navigate into folders + store.getState().setBreadcrumbs(['company-docs']) + store.getState().setPrefix(['projects']) + const folderFiles = [ + createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder), + createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder), + createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file), + ] + store.getState().setOnlineDriveFileList(folderFiles) + + // Step 4: Navigate deeper + store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha']) + store.getState().setPrefix([...store.getState().prefix, 'project-alpha']) + + // Step 5: Select files + store.getState().setOnlineDriveFileList([ + createOnlineDriveFile('doc-1', 'spec.pdf'), + createOnlineDriveFile('doc-2', 'design.fig'), + ]) + store.getState().setSelectedFileIds(['doc-1']) + + // Verify full state + expect(store.getState().currentCredentialId).toBe('drive-cred-1') + expect(store.getState().bucket).toBe('company-docs') + expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha']) + expect(store.getState().prefix).toEqual(['projects', 'project-alpha']) + expect(store.getState().onlineDriveFileList).toHaveLength(2) + expect(store.getState().selectedFileIds).toEqual(['doc-1']) + expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf') + }) + }) +}) diff --git a/web/__tests__/datasets/segment-crud.test.tsx b/web/__tests__/datasets/segment-crud.test.tsx new file mode 100644 index 0000000000..9190e17395 --- /dev/null +++ b/web/__tests__/datasets/segment-crud.test.tsx @@ -0,0 +1,301 @@ +/** + * Integration Test: Segment CRUD Flow + * + * Tests segment selection, search/filter, and modal state management across hooks. + * Validates cross-hook data contracts in the completed segment module. + */ + +import type { SegmentDetailModel } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useModalState } from '@/app/components/datasets/documents/detail/completed/hooks/use-modal-state' +import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter' +import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection' + +const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => ({ + id, + position: 1, + document_id: 'doc-1', + content, + sign_content: content, + answer: '', + word_count: 50, + tokens: 25, + keywords: ['test'], + index_node_id: 'idx-1', + index_node_hash: 'hash-1', + hit_count: 0, + enabled: true, + disabled_at: 0, + disabled_by: '', + status: 'completed', + created_by: 'user-1', + created_at: Date.now(), + indexing_at: Date.now(), + completed_at: Date.now(), + error: null, + stopped_at: 0, + updated_at: Date.now(), + attachments: [], +} as SegmentDetailModel) + +describe('Segment CRUD Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Search and Filter → Segment List Query', () => { + it('should manage search input with debounce', () => { + vi.useFakeTimers() + const onPageChange = vi.fn() + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.handleInputChange('keyword') + }) + + expect(result.current.inputValue).toBe('keyword') + expect(result.current.searchValue).toBe('') + + act(() => { + vi.advanceTimersByTime(500) + }) + expect(result.current.searchValue).toBe('keyword') + expect(onPageChange).toHaveBeenCalledWith(1) + + vi.useRealTimers() + }) + + it('should manage status filter state', () => { + const onPageChange = vi.fn() + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + // status value 1 maps to !!1 = true (enabled) + act(() => { + result.current.onChangeStatus({ value: 1, name: 'enabled' }) + }) + // onChangeStatus converts: value === 'all' ? 'all' : !!value + expect(result.current.selectedStatus).toBe(true) + + act(() => { + result.current.onClearFilter() + }) + expect(result.current.selectedStatus).toBe('all') + expect(result.current.inputValue).toBe('') + }) + + it('should provide status list for filter dropdown', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() })) + expect(result.current.statusList).toBeInstanceOf(Array) + expect(result.current.statusList.length).toBe(3) // all, disabled, enabled + }) + + it('should compute selectDefaultValue based on selectedStatus', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() })) + + // Initial state: 'all' + expect(result.current.selectDefaultValue).toBe('all') + + // Set to enabled (true) + act(() => { + result.current.onChangeStatus({ value: 1, name: 'enabled' }) + }) + expect(result.current.selectDefaultValue).toBe(1) + + // Set to disabled (false) + act(() => { + result.current.onChangeStatus({ value: 0, name: 'disabled' }) + }) + expect(result.current.selectDefaultValue).toBe(0) + }) + }) + + describe('Segment Selection → Batch Operations', () => { + const segments = [ + createSegment('seg-1'), + createSegment('seg-2'), + createSegment('seg-3'), + ] + + it('should manage individual segment selection', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + expect(result.current.selectedSegmentIds).toContain('seg-1') + + act(() => { + result.current.onSelected('seg-2') + }) + expect(result.current.selectedSegmentIds).toContain('seg-1') + expect(result.current.selectedSegmentIds).toContain('seg-2') + expect(result.current.selectedSegmentIds).toHaveLength(2) + }) + + it('should toggle selection on repeated click', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + expect(result.current.selectedSegmentIds).toContain('seg-1') + + act(() => { + result.current.onSelected('seg-1') + }) + expect(result.current.selectedSegmentIds).not.toContain('seg-1') + }) + + it('should support select all toggle', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelectedAll() + }) + expect(result.current.selectedSegmentIds).toHaveLength(3) + expect(result.current.isAllSelected).toBe(true) + + act(() => { + result.current.onSelectedAll() + }) + expect(result.current.selectedSegmentIds).toHaveLength(0) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should detect partial selection via isSomeSelected', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + + // After selecting one of three, isSomeSelected should be true + expect(result.current.selectedSegmentIds).toEqual(['seg-1']) + expect(result.current.isSomeSelected).toBe(true) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should clear selection via onCancelBatchOperation', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + result.current.onSelected('seg-2') + }) + expect(result.current.selectedSegmentIds).toHaveLength(2) + + act(() => { + result.current.onCancelBatchOperation() + }) + expect(result.current.selectedSegmentIds).toHaveLength(0) + }) + }) + + describe('Modal State Management', () => { + const onNewSegmentModalChange = vi.fn() + + it('should open segment detail modal on card click', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + const segment = createSegment('seg-detail-1', 'Detail content') + act(() => { + result.current.onClickCard(segment) + }) + expect(result.current.currSegment.showModal).toBe(true) + expect(result.current.currSegment.segInfo).toBeDefined() + expect(result.current.currSegment.segInfo!.id).toBe('seg-detail-1') + }) + + it('should close segment detail modal', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + const segment = createSegment('seg-1') + act(() => { + result.current.onClickCard(segment) + }) + expect(result.current.currSegment.showModal).toBe(true) + + act(() => { + result.current.onCloseSegmentDetail() + }) + expect(result.current.currSegment.showModal).toBe(false) + }) + + it('should manage full screen toggle', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + expect(result.current.fullScreen).toBe(false) + act(() => { + result.current.toggleFullScreen() + }) + expect(result.current.fullScreen).toBe(true) + act(() => { + result.current.toggleFullScreen() + }) + expect(result.current.fullScreen).toBe(false) + }) + + it('should manage collapsed state', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + expect(result.current.isCollapsed).toBe(true) + act(() => { + result.current.toggleCollapsed() + }) + expect(result.current.isCollapsed).toBe(false) + }) + + it('should manage new child segment modal', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + expect(result.current.showNewChildSegmentModal).toBe(false) + act(() => { + result.current.handleAddNewChildChunk('chunk-parent-1') + }) + expect(result.current.showNewChildSegmentModal).toBe(true) + expect(result.current.currChunkId).toBe('chunk-parent-1') + + act(() => { + result.current.onCloseNewChildChunkModal() + }) + expect(result.current.showNewChildSegmentModal).toBe(false) + }) + }) + + describe('Cross-Hook Data Flow: Search → Selection → Modal', () => { + it('should maintain independent state across all three hooks', () => { + const segments = [createSegment('seg-1'), createSegment('seg-2')] + + const { result: filterResult } = renderHook(() => + useSearchFilter({ onPageChange: vi.fn() }), + ) + const { result: selectionResult } = renderHook(() => + useSegmentSelection(segments), + ) + const { result: modalResult } = renderHook(() => + useModalState({ onNewSegmentModalChange: vi.fn() }), + ) + + // Set search filter to enabled + act(() => { + filterResult.current.onChangeStatus({ value: 1, name: 'enabled' }) + }) + + // Select a segment + act(() => { + selectionResult.current.onSelected('seg-1') + }) + + // Open detail modal + act(() => { + modalResult.current.onClickCard(segments[0]) + }) + + // All states should be independent + expect(filterResult.current.selectedStatus).toBe(true) // !!1 + expect(selectionResult.current.selectedSegmentIds).toContain('seg-1') + expect(modalResult.current.currSegment.showModal).toBe(true) + }) + }) +}) diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx index 4088e709d1..06563832f1 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx @@ -162,8 +162,10 @@ describe('useEmbeddedChatbot', () => { await waitFor(() => { expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1') }) - expect(result.current.pinnedConversationList).toEqual(pinnedData.data) - expect(result.current.conversationList).toEqual(listData.data) + await waitFor(() => { + expect(result.current.pinnedConversationList).toEqual(pinnedData.data) + expect(result.current.conversationList).toEqual(listData.data) + }) }) }) diff --git a/web/app/components/datasets/__tests__/chunk.spec.tsx b/web/app/components/datasets/__tests__/chunk.spec.tsx new file mode 100644 index 0000000000..eea972cb17 --- /dev/null +++ b/web/app/components/datasets/__tests__/chunk.spec.tsx @@ -0,0 +1,309 @@ +import type { QA } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkContainer, ChunkLabel, QAPreview } from '../chunk' + +vi.mock('../../base/icons/src/public/knowledge', () => ({ + SelectionMod: (props: React.ComponentProps<'svg'>) => ( + + ), +})) + +function createQA(overrides: Partial = {}): QA { + return { + question: 'What is Dify?', + answer: 'Dify is an open-source LLM app development platform.', + ...overrides, + } +} + +describe('ChunkLabel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the label text', () => { + render() + + expect(screen.getByText('Chunk #1')).toBeInTheDocument() + }) + + it('should render the character count with unit', () => { + render() + + expect(screen.getByText('256 characters')).toBeInTheDocument() + }) + + it('should render the SelectionMod icon', () => { + render() + + expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument() + }) + + it('should render a middle dot separator between label and count', () => { + render() + + expect(screen.getByText('·')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should display zero character count', () => { + render() + + expect(screen.getByText('0 characters')).toBeInTheDocument() + }) + + it('should display large character counts', () => { + render() + + expect(screen.getByText('999999 characters')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with empty label', () => { + render() + + expect(screen.getByText('50 characters')).toBeInTheDocument() + }) + + it('should render with special characters in label', () => { + render() + + expect(screen.getByText('Chunk <#1> & \'test\'')).toBeInTheDocument() + }) + }) +}) + +// Tests for ChunkContainer - wraps ChunkLabel with children content area +describe('ChunkContainer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render ChunkLabel with correct props', () => { + render( + + Content here + , + ) + + expect(screen.getByText('Chunk #1')).toBeInTheDocument() + expect(screen.getByText('200 characters')).toBeInTheDocument() + }) + + it('should render children in the content area', () => { + render( + +

Paragraph content

+
, + ) + + expect(screen.getByText('Paragraph content')).toBeInTheDocument() + }) + + it('should render the SelectionMod icon via ChunkLabel', () => { + render( + + Content + , + ) + + expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument() + }) + }) + + describe('Structure', () => { + it('should have space-y-2 on the outer container', () => { + const { container } = render( + Content, + ) + + expect(container.firstElementChild).toHaveClass('space-y-2') + }) + + it('should render children inside a styled content div', () => { + render( + + Test child + , + ) + + const contentDiv = screen.getByText('Test child').parentElement + expect(contentDiv).toHaveClass('body-md-regular', 'text-text-secondary') + }) + }) + + describe('Edge Cases', () => { + it('should render without children', () => { + const { container } = render( + , + ) + + expect(container.firstElementChild).toBeInTheDocument() + expect(screen.getByText('Empty')).toBeInTheDocument() + }) + + it('should render multiple children', () => { + render( + + First + Second + , + ) + + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.getByText('Second')).toBeInTheDocument() + }) + + it('should render with string children', () => { + render( + + Plain text content + , + ) + + expect(screen.getByText('Plain text content')).toBeInTheDocument() + }) + }) +}) + +// Tests for QAPreview - displays question and answer pair +describe('QAPreview', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the question text', () => { + const qa = createQA() + render() + + expect(screen.getByText('What is Dify?')).toBeInTheDocument() + }) + + it('should render the answer text', () => { + const qa = createQA() + render() + + expect(screen.getByText('Dify is an open-source LLM app development platform.')).toBeInTheDocument() + }) + + it('should render Q and A labels', () => { + const qa = createQA() + render() + + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + }) + }) + + describe('Structure', () => { + it('should render Q label as a label element', () => { + const qa = createQA() + render() + + const qLabel = screen.getByText('Q') + expect(qLabel.tagName).toBe('LABEL') + }) + + it('should render A label as a label element', () => { + const qa = createQA() + render() + + const aLabel = screen.getByText('A') + expect(aLabel.tagName).toBe('LABEL') + }) + + it('should render question in a p element', () => { + const qa = createQA() + render() + + const questionEl = screen.getByText(qa.question) + expect(questionEl.tagName).toBe('P') + }) + + it('should render answer in a p element', () => { + const qa = createQA() + render() + + const answerEl = screen.getByText(qa.answer) + expect(answerEl.tagName).toBe('P') + }) + + it('should have the outer container with flex column layout', () => { + const qa = createQA() + const { container } = render() + + expect(container.firstElementChild).toHaveClass('flex', 'flex-col', 'gap-y-2') + }) + + it('should apply text styling classes to question paragraph', () => { + const qa = createQA() + render() + + const questionEl = screen.getByText(qa.question) + expect(questionEl).toHaveClass('body-md-regular', 'text-text-secondary') + }) + + it('should apply text styling classes to answer paragraph', () => { + const qa = createQA() + render() + + const answerEl = screen.getByText(qa.answer) + expect(answerEl).toHaveClass('body-md-regular', 'text-text-secondary') + }) + }) + + describe('Edge Cases', () => { + it('should render with empty question', () => { + const qa = createQA({ question: '' }) + render() + + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + }) + + it('should render with empty answer', () => { + const qa = createQA({ answer: '' }) + render() + + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText(qa.question)).toBeInTheDocument() + }) + + it('should render with long text', () => { + const longText = 'x'.repeat(1000) + const qa = createQA({ question: longText, answer: longText }) + render() + + const elements = screen.getAllByText(longText) + expect(elements).toHaveLength(2) + }) + + it('should render with special characters in question and answer', () => { + const qa = createQA({ + question: 'What about & "quotes"?', + answer: 'It handles \'single\' & "double" quotes.', + }) + render() + + expect(screen.getByText('What about & "quotes"?')).toBeInTheDocument() + expect(screen.getByText('It handles \'single\' & "double" quotes.')).toBeInTheDocument() + }) + + it('should render with multiline text', () => { + const qa = createQA({ + question: 'Line1\nLine2', + answer: 'Answer1\nAnswer2', + }) + render() + + expect(screen.getByText(/Line1/)).toBeInTheDocument() + expect(screen.getByText(/Answer1/)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/loading.spec.tsx b/web/app/components/datasets/__tests__/loading.spec.tsx similarity index 92% rename from web/app/components/datasets/loading.spec.tsx rename to web/app/components/datasets/__tests__/loading.spec.tsx index 0b291d727f..7e35399485 100644 --- a/web/app/components/datasets/loading.spec.tsx +++ b/web/app/components/datasets/__tests__/loading.spec.tsx @@ -1,6 +1,6 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import DatasetsLoading from './loading' +import DatasetsLoading from '../loading' afterEach(() => { cleanup() diff --git a/web/app/components/datasets/no-linked-apps-panel.spec.tsx b/web/app/components/datasets/__tests__/no-linked-apps-panel.spec.tsx similarity index 78% rename from web/app/components/datasets/no-linked-apps-panel.spec.tsx rename to web/app/components/datasets/__tests__/no-linked-apps-panel.spec.tsx index aa66e43fbd..d6f0dfeabb 100644 --- a/web/app/components/datasets/no-linked-apps-panel.spec.tsx +++ b/web/app/components/datasets/__tests__/no-linked-apps-panel.spec.tsx @@ -1,13 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import NoLinkedAppsPanel from './no-linked-apps-panel' - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import NoLinkedAppsPanel from '../no-linked-apps-panel' // Mock useDocLink vi.mock('@/context/i18n', () => ({ @@ -21,17 +14,17 @@ afterEach(() => { describe('NoLinkedAppsPanel', () => { it('should render without crashing', () => { render() - expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument() + expect(screen.getByText('common.datasetMenus.emptyTip')).toBeInTheDocument() }) it('should render the empty tip text', () => { render() - expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument() + expect(screen.getByText('common.datasetMenus.emptyTip')).toBeInTheDocument() }) it('should render the view doc link', () => { render() - expect(screen.getByText('datasetMenus.viewDoc')).toBeInTheDocument() + expect(screen.getByText('common.datasetMenus.viewDoc')).toBeInTheDocument() }) it('should render link with correct href', () => { diff --git a/web/app/components/datasets/api/index.spec.tsx b/web/app/components/datasets/api/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/datasets/api/index.spec.tsx rename to web/app/components/datasets/api/__tests__/index.spec.tsx index 33ee656a23..f3c5e2ffc3 100644 --- a/web/app/components/datasets/api/index.spec.tsx +++ b/web/app/components/datasets/api/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import ApiIndex from './index' +import ApiIndex from '../index' afterEach(() => { cleanup() diff --git a/web/app/components/datasets/chunk.spec.tsx b/web/app/components/datasets/chunk.spec.tsx deleted file mode 100644 index d3dc011aef..0000000000 --- a/web/app/components/datasets/chunk.spec.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { ChunkContainer, ChunkLabel, QAPreview } from './chunk' - -afterEach(() => { - cleanup() -}) - -describe('ChunkLabel', () => { - it('should render label text', () => { - render() - expect(screen.getByText('Chunk 1')).toBeInTheDocument() - }) - - it('should render character count', () => { - render() - expect(screen.getByText('150 characters')).toBeInTheDocument() - }) - - it('should render separator dot', () => { - render() - expect(screen.getByText('·')).toBeInTheDocument() - }) - - it('should render with zero character count', () => { - render() - expect(screen.getByText('0 characters')).toBeInTheDocument() - }) - - it('should render with large character count', () => { - render() - expect(screen.getByText('999999 characters')).toBeInTheDocument() - }) -}) - -describe('ChunkContainer', () => { - it('should render label and character count', () => { - render(Content) - expect(screen.getByText('Container 1')).toBeInTheDocument() - expect(screen.getByText('200 characters')).toBeInTheDocument() - }) - - it('should render children content', () => { - render(Test Content) - expect(screen.getByText('Test Content')).toBeInTheDocument() - }) - - it('should render with complex children', () => { - render( - -
- Nested content -
-
, - ) - expect(screen.getByTestId('child-div')).toBeInTheDocument() - expect(screen.getByText('Nested content')).toBeInTheDocument() - }) - - it('should render empty children', () => { - render({null}) - expect(screen.getByText('Empty')).toBeInTheDocument() - }) -}) - -describe('QAPreview', () => { - const mockQA = { - question: 'What is the meaning of life?', - answer: 'The meaning of life is 42.', - } - - it('should render question text', () => { - render() - expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument() - }) - - it('should render answer text', () => { - render() - expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument() - }) - - it('should render Q label', () => { - render() - expect(screen.getByText('Q')).toBeInTheDocument() - }) - - it('should render A label', () => { - render() - expect(screen.getByText('A')).toBeInTheDocument() - }) - - it('should render with empty strings', () => { - render() - expect(screen.getByText('Q')).toBeInTheDocument() - expect(screen.getByText('A')).toBeInTheDocument() - }) - - it('should render with long text', () => { - const longQuestion = 'Q'.repeat(500) - const longAnswer = 'A'.repeat(500) - render() - expect(screen.getByText(longQuestion)).toBeInTheDocument() - expect(screen.getByText(longAnswer)).toBeInTheDocument() - }) - - it('should render with special characters', () => { - render(?', answer: '& special chars!' }} />) - expect(screen.getByText('What about \n\t& < > "' mockFetchFilePreview.mockResolvedValue({ content: specialContent }) - // Act const { container } = renderFilePreview() // Assert - Should render as text, not execute scripts @@ -607,25 +506,20 @@ describe('FilePreview', () => { }) it('should handle preview content with unicode', async () => { - // Arrange const unicodeContent = 'äž­æ–‡ć†…ćźč 🚀 Ă©mojis & spĂ«cĂźal çhĂ rs' mockFetchFilePreview.mockResolvedValue({ content: unicodeContent }) - // Act renderFilePreview() - // Assert await waitFor(() => { expect(screen.getByText(unicodeContent)).toBeInTheDocument() }) }) it('should handle preview content with newlines', async () => { - // Arrange const multilineContent = 'Line 1\nLine 2\nLine 3' mockFetchFilePreview.mockResolvedValue({ content: multilineContent }) - // Act const { container } = renderFilePreview() // Assert - Content should be in the DOM @@ -639,10 +533,8 @@ describe('FilePreview', () => { }) it('should handle null content from API', async () => { - // Arrange mockFetchFilePreview.mockResolvedValue({ content: null as unknown as string }) - // Act const { container } = renderFilePreview() // Assert - Should not crash @@ -652,16 +544,12 @@ describe('FilePreview', () => { }) }) - // -------------------------------------------------------------------------- // Side Effects and Cleanup Tests - // -------------------------------------------------------------------------- describe('Side Effects and Cleanup', () => { it('should trigger effect when file prop changes', async () => { - // Arrange const file1 = createMockFile({ id: 'file-1' }) const file2 = createMockFile({ id: 'file-2' }) - // Act const { rerender } = render( , ) @@ -672,19 +560,16 @@ describe('FilePreview', () => { rerender() - // Assert await waitFor(() => { expect(mockFetchFilePreview).toHaveBeenCalledTimes(2) }) }) it('should not trigger effect when hidePreview changes', async () => { - // Arrange const file = createMockFile() const hidePreview1 = vi.fn() const hidePreview2 = vi.fn() - // Act const { rerender } = render( , ) @@ -703,11 +588,9 @@ describe('FilePreview', () => { }) it('should handle rapid file changes', async () => { - // Arrange const files = Array.from({ length: 5 }, (_, i) => createMockFile({ id: `file-${i}` })) - // Act const { rerender } = render( , ) @@ -723,12 +606,10 @@ describe('FilePreview', () => { }) it('should handle unmount during loading', async () => { - // Arrange mockFetchFilePreview.mockImplementation( () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)), ) - // Act const { unmount } = renderFilePreview() // Unmount before API resolves @@ -739,10 +620,8 @@ describe('FilePreview', () => { }) it('should handle file changing from defined to undefined', async () => { - // Arrange const file = createMockFile() - // Act const { rerender, container } = render( , ) @@ -759,26 +638,19 @@ describe('FilePreview', () => { }) }) - // -------------------------------------------------------------------------- // getFileName Helper Tests - // -------------------------------------------------------------------------- describe('getFileName Helper', () => { it('should extract name without extension for simple filename', async () => { - // Arrange const file = createMockFile({ name: 'document.pdf' }) - // Act renderFilePreview({ file }) - // Assert expect(screen.getByText('document')).toBeInTheDocument() }) it('should handle filename with multiple dots', async () => { - // Arrange const file = createMockFile({ name: 'file.name.with.dots.txt' }) - // Act renderFilePreview({ file }) // Assert - Should join all parts except last with comma @@ -786,10 +658,8 @@ describe('FilePreview', () => { }) it('should return empty for filename without dot', async () => { - // Arrange const file = createMockFile({ name: 'nodotfile' }) - // Act const { container } = renderFilePreview({ file }) // Assert - slice(0, -1) on single element array returns empty @@ -799,7 +669,6 @@ describe('FilePreview', () => { }) it('should return empty string when file is undefined', async () => { - // Arrange & Act const { container } = renderFilePreview({ file: undefined }) // Assert - File name area should have empty first span @@ -808,38 +677,27 @@ describe('FilePreview', () => { }) }) - // -------------------------------------------------------------------------- - // Accessibility Tests - // -------------------------------------------------------------------------- describe('Accessibility', () => { it('should have clickable close button with visual indicator', async () => { - // Arrange & Act const { container } = renderFilePreview() - // Assert const closeButton = container.querySelector('.cursor-pointer') expect(closeButton).toBeInTheDocument() expect(closeButton).toHaveClass('cursor-pointer') }) it('should have proper heading structure', async () => { - // Arrange & Act renderFilePreview() - // Assert expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Error Handling Tests - // -------------------------------------------------------------------------- describe('Error Handling', () => { it('should not crash on API network error', async () => { - // Arrange mockFetchFilePreview.mockRejectedValue(new Error('Network Error')) - // Act const { container } = renderFilePreview() // Assert - Component should still render @@ -849,26 +707,20 @@ describe('FilePreview', () => { }) it('should not crash on API timeout', async () => { - // Arrange mockFetchFilePreview.mockRejectedValue(new Error('Timeout')) - // Act const { container } = renderFilePreview() - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) }) it('should not crash on malformed API response', async () => { - // Arrange mockFetchFilePreview.mockResolvedValue({} as { content: string }) - // Act const { container } = renderFilePreview() - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) diff --git a/web/app/components/datasets/create/file-uploader/index.spec.tsx b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx similarity index 87% rename from web/app/components/datasets/create/file-uploader/index.spec.tsx rename to web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx index 91f65652f3..da337efce2 100644 --- a/web/app/components/datasets/create/file-uploader/index.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx @@ -1,26 +1,9 @@ import type { CustomFile as File, FileItem } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PROGRESS_NOT_STARTED } from './constants' -import FileUploader from './index' +import { PROGRESS_NOT_STARTED } from '../constants' +import FileUploader from '../index' -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'stepOne.uploader.title': 'Upload Files', - 'stepOne.uploader.button': 'Drag and drop files, or', - 'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or', - 'stepOne.uploader.browse': 'Browse', - 'stepOne.uploader.tip': 'Supports various file types', - } - return translations[key] || key - }, - }), -})) - -// Mock ToastContext const mockNotify = vi.fn() vi.mock('use-context-selector', async () => { const actual = await vi.importActual('use-context-selector') @@ -118,22 +101,22 @@ describe('FileUploader', () => { describe('rendering', () => { it('should render the component', () => { render() - expect(screen.getByText('Upload Files')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.uploader.title')).toBeInTheDocument() }) it('should render dropzone when no files', () => { render() - expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument() }) it('should render browse button', () => { render() - expect(screen.getByText('Browse')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument() }) it('should apply custom title className', () => { render() - const title = screen.getByText('Upload Files') + const title = screen.getByText('datasetCreation.stepOne.uploader.title') expect(title).toHaveClass('custom-class') }) }) @@ -162,19 +145,19 @@ describe('FileUploader', () => { describe('batch upload mode', () => { it('should show dropzone with batch upload enabled', () => { render() - expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument() }) it('should show single file text when batch upload disabled', () => { render() - expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument() }) it('should hide dropzone when not batch upload and has files', () => { const fileList = [createMockFileItem()] render() - expect(screen.queryByText(/Drag and drop/i)).not.toBeInTheDocument() + expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.button/)).not.toBeInTheDocument() }) }) @@ -217,7 +200,7 @@ describe('FileUploader', () => { render() // The browse label should trigger file input click - const browseLabel = screen.getByText('Browse') + const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse') expect(browseLabel).toHaveClass('cursor-pointer') }) }) diff --git a/web/app/components/datasets/create/file-uploader/components/file-list-item.spec.tsx b/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx similarity index 98% rename from web/app/components/datasets/create/file-uploader/components/file-list-item.spec.tsx rename to web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx index 4da20a7bf7..dd88af4395 100644 --- a/web/app/components/datasets/create/file-uploader/components/file-list-item.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx @@ -1,9 +1,9 @@ -import type { FileListItemProps } from './file-list-item' +import type { FileListItemProps } from '../file-list-item' import type { CustomFile as File, FileItem } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' -import FileListItem from './file-list-item' +import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' +import FileListItem from '../file-list-item' // Mock theme hook - can be changed per test let mockTheme = 'light' diff --git a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.spec.tsx b/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx similarity index 84% rename from web/app/components/datasets/create/file-uploader/components/upload-dropzone.spec.tsx rename to web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx index 112d61250b..ee769c110e 100644 --- a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx @@ -1,33 +1,12 @@ import type { RefObject } from 'react' -import type { UploadDropzoneProps } from './upload-dropzone' +import type { UploadDropzoneProps } from '../upload-dropzone' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import UploadDropzone from './upload-dropzone' +import UploadDropzone from '../upload-dropzone' // Helper to create mock ref objects for testing const createMockRef = (value: T | null = null): RefObject => ({ current: value }) -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record) => { - const translations: Record = { - 'stepOne.uploader.button': 'Drag and drop files, or', - 'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or', - 'stepOne.uploader.browse': 'Browse', - 'stepOne.uploader.tip': 'Supports {{supportTypes}}, Max {{size}}MB each, up to {{batchCount}} files at a time, {{totalCount}} files total', - } - let result = translations[key] || key - if (options && typeof options === 'object') { - Object.entries(options).forEach(([k, v]) => { - result = result.replace(`{{${k}}}`, String(v)) - }) - } - return result - }, - }), -})) - describe('UploadDropzone', () => { const defaultProps: UploadDropzoneProps = { dropRef: createMockRef() as RefObject, @@ -73,17 +52,17 @@ describe('UploadDropzone', () => { it('should render browse label when extensions are allowed', () => { render() - expect(screen.getByText('Browse')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument() }) it('should not render browse label when no extensions allowed', () => { render() - expect(screen.queryByText('Browse')).not.toBeInTheDocument() + expect(screen.queryByText('datasetCreation.stepOne.uploader.browse')).not.toBeInTheDocument() }) it('should render file size and count limits', () => { render() - const tipText = screen.getByText(/Supports.*Max.*15MB/i) + const tipText = screen.getByText(/datasetCreation\.stepOne\.uploader\.tip/) expect(tipText).toBeInTheDocument() }) }) @@ -111,12 +90,12 @@ describe('UploadDropzone', () => { describe('text content', () => { it('should show batch upload text when supportBatchUpload is true', () => { render() - expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument() }) it('should show single file text when supportBatchUpload is false', () => { render() - expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument() }) }) @@ -146,7 +125,7 @@ describe('UploadDropzone', () => { const onSelectFile = vi.fn() render() - const browseLabel = screen.getByText('Browse') + const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse') fireEvent.click(browseLabel) expect(onSelectFile).toHaveBeenCalledTimes(1) @@ -195,7 +174,7 @@ describe('UploadDropzone', () => { it('should have cursor-pointer on browse label', () => { render() - const browseLabel = screen.getByText('Browse') + const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse') expect(browseLabel).toHaveClass('cursor-pointer') }) }) diff --git a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.spec.tsx b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx similarity index 99% rename from web/app/components/datasets/create/file-uploader/hooks/use-file-upload.spec.tsx rename to web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx index 222f038c84..b5d1a96554 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx @@ -4,15 +4,14 @@ import { act, render, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ToastContext } from '@/app/components/base/toast' -import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' +import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' // Import after mocks -import { useFileUpload } from './use-file-upload' +import { useFileUpload } from '../use-file-upload' // Mock notify function const mockNotify = vi.fn() const mockClose = vi.fn() -// Mock ToastContext vi.mock('use-context-selector', async () => { const actual = await vi.importActual('use-context-selector') return { @@ -44,12 +43,6 @@ vi.mock('@/service/use-common', () => ({ })) // Mock i18n -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock locale vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', @@ -59,7 +52,6 @@ vi.mock('@/i18n-config/language', () => ({ LanguagesSupported: ['en-US', 'zh-Hans'], })) -// Mock config vi.mock('@/config', () => ({ IS_CE_EDITION: false, })) diff --git a/web/app/components/datasets/create/notion-page-preview/index.spec.tsx b/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/datasets/create/notion-page-preview/index.spec.tsx rename to web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx index 78b54dc8af..572677ced7 100644 --- a/web/app/components/datasets/create/notion-page-preview/index.spec.tsx +++ b/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { MockedFunction } from 'vitest' import type { NotionPage } from '@/models/common' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { fetchNotionPagePreview } from '@/service/datasets' -import NotionPagePreview from './index' +import NotionPagePreview from '../index' // Mock the fetchNotionPagePreview service vi.mock('@/service/datasets', () => ({ @@ -85,13 +85,10 @@ const findLoadingSpinner = (container: HTMLElement) => { return container.querySelector('.spin-animation') } -// ============================================================================ // NotionPagePreview Component Tests -// ============================================================================ // Note: Branch coverage is ~88% because line 29 (`if (!currentPage) return`) // is defensive code that cannot be reached - getPreviewContent is only called // from useEffect when currentPage is truthy. -// ============================================================================ describe('NotionPagePreview', () => { beforeEach(() => { vi.clearAllMocks() @@ -106,31 +103,23 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- // Rendering Tests - Verify component renders properly - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', async () => { - // Arrange & Act await renderNotionPagePreview() - // Assert expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() }) it('should render page preview header', async () => { - // Arrange & Act await renderNotionPagePreview() - // Assert expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() }) it('should render close button with XMarkIcon', async () => { - // Arrange & Act const { container } = await renderNotionPagePreview() - // Assert const closeButton = container.querySelector('.cursor-pointer') expect(closeButton).toBeInTheDocument() const xMarkIcon = closeButton?.querySelector('svg') @@ -138,30 +127,23 @@ describe('NotionPagePreview', () => { }) it('should render page name', async () => { - // Arrange const page = createMockNotionPage({ page_name: 'My Notion Page' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('My Notion Page')).toBeInTheDocument() }) it('should apply correct CSS classes to container', async () => { - // Arrange & Act const { container } = await renderNotionPagePreview() - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('h-full') }) it('should render NotionIcon component', async () => { - // Arrange const page = createMockNotionPage() - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - NotionIcon should be rendered (either as img or div or svg) @@ -170,15 +152,11 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- // NotionIcon Rendering Tests - // -------------------------------------------------------------------------- describe('NotionIcon Rendering', () => { it('should render default icon when page_icon is null', async () => { - // Arrange const page = createMockNotionPage({ page_icon: null }) - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - Should render RiFileTextLine icon (svg) @@ -187,33 +165,25 @@ describe('NotionPagePreview', () => { }) it('should render emoji icon when page_icon has emoji type', async () => { - // Arrange const page = createMockNotionPageWithEmojiIcon('📝') - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('📝')).toBeInTheDocument() }) it('should render image icon when page_icon has url type', async () => { - // Arrange const page = createMockNotionPageWithUrlIcon('https://example.com/icon.png') - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) - // Assert const img = container.querySelector('img[alt="page icon"]') expect(img).toBeInTheDocument() expect(img).toHaveAttribute('src', 'https://example.com/icon.png') }) }) - // -------------------------------------------------------------------------- // Loading State Tests - // -------------------------------------------------------------------------- describe('Loading State', () => { it('should show loading indicator initially', async () => { // Arrange - Delay API response to keep loading state @@ -230,13 +200,10 @@ describe('NotionPagePreview', () => { }) it('should hide loading indicator after content loads', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: 'Loaded content' }) - // Act const { container } = await renderNotionPagePreview() - // Assert expect(screen.getByText('Loaded content')).toBeInTheDocument() // Loading should be gone const loadingElement = findLoadingSpinner(container) @@ -244,7 +211,6 @@ describe('NotionPagePreview', () => { }) it('should show loading when currentPage changes', async () => { - // Arrange const page1 = createMockNotionPage({ page_id: 'page-1', page_name: 'Page 1' }) const page2 = createMockNotionPage({ page_id: 'page-2', page_name: 'Page 2' }) @@ -291,24 +257,19 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- // API Call Tests - // -------------------------------------------------------------------------- describe('API Calls', () => { it('should call fetchNotionPagePreview with correct parameters', async () => { - // Arrange const page = createMockNotionPage({ page_id: 'test-page-id', type: 'database', }) - // Act await renderNotionPagePreview({ currentPage: page, notionCredentialId: 'test-credential-id', }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({ pageID: 'test-page-id', pageType: 'database', @@ -317,19 +278,15 @@ describe('NotionPagePreview', () => { }) it('should not call fetchNotionPagePreview when currentPage is undefined', async () => { - // Arrange & Act await renderNotionPagePreview({ currentPage: undefined }, false) - // Assert expect(mockFetchNotionPagePreview).not.toHaveBeenCalled() }) it('should call fetchNotionPagePreview again when currentPage changes', async () => { - // Arrange const page1 = createMockNotionPage({ page_id: 'page-1' }) const page2 = createMockNotionPage({ page_id: 'page-2' }) - // Act const { rerender } = render( , ) @@ -346,7 +303,6 @@ describe('NotionPagePreview', () => { rerender() }) - // Assert await waitFor(() => { expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({ pageID: 'page-2', @@ -358,21 +314,16 @@ describe('NotionPagePreview', () => { }) it('should handle API success and display content', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: 'Notion page preview content from API' }) - // Act await renderNotionPagePreview() - // Assert expect(screen.getByText('Notion page preview content from API')).toBeInTheDocument() }) it('should handle API error gracefully', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('Network error')) - // Act const { container } = await renderNotionPagePreview({}, false) // Assert - Component should not crash @@ -384,10 +335,8 @@ describe('NotionPagePreview', () => { }) it('should handle empty content response', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: '' }) - // Act const { container } = await renderNotionPagePreview() // Assert - Should still render without loading @@ -396,42 +345,30 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should call hidePreview when close button is clicked', async () => { - // Arrange const hidePreview = vi.fn() const { container } = await renderNotionPagePreview({ hidePreview }) - // Act const closeButton = container.querySelector('.cursor-pointer') as HTMLElement fireEvent.click(closeButton) - // Assert expect(hidePreview).toHaveBeenCalledTimes(1) }) it('should handle multiple clicks on close button', async () => { - // Arrange const hidePreview = vi.fn() const { container } = await renderNotionPagePreview({ hidePreview }) - // Act const closeButton = container.querySelector('.cursor-pointer') as HTMLElement fireEvent.click(closeButton) fireEvent.click(closeButton) fireEvent.click(closeButton) - // Assert expect(hidePreview).toHaveBeenCalledTimes(3) }) }) - // -------------------------------------------------------------------------- - // State Management Tests - // -------------------------------------------------------------------------- describe('State Management', () => { it('should initialize with loading state true', async () => { // Arrange - Keep loading indefinitely (never resolves) @@ -440,24 +377,19 @@ describe('NotionPagePreview', () => { // Act - Don't wait for content const { container } = await renderNotionPagePreview({}, false) - // Assert const loadingElement = findLoadingSpinner(container) expect(loadingElement).toBeInTheDocument() }) it('should update previewContent state after successful fetch', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: 'New preview content' }) - // Act await renderNotionPagePreview() - // Assert expect(screen.getByText('New preview content')).toBeInTheDocument() }) it('should reset loading to true when currentPage changes', async () => { - // Arrange const page1 = createMockNotionPage({ page_id: 'page-1' }) const page2 = createMockNotionPage({ page_id: 'page-2' }) @@ -465,7 +397,6 @@ describe('NotionPagePreview', () => { .mockResolvedValueOnce({ content: 'Content 1' }) .mockImplementationOnce(() => new Promise(() => { /* never resolves */ })) - // Act const { rerender, container } = render( , ) @@ -487,7 +418,6 @@ describe('NotionPagePreview', () => { }) it('should replace old content with new content when page changes', async () => { - // Arrange const page1 = createMockNotionPage({ page_id: 'page-1' }) const page2 = createMockNotionPage({ page_id: 'page-2' }) @@ -497,7 +427,6 @@ describe('NotionPagePreview', () => { .mockResolvedValueOnce({ content: 'Content 1' }) .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) - // Act const { rerender } = render( , ) @@ -523,24 +452,17 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- - // Props Testing - // -------------------------------------------------------------------------- describe('Props', () => { describe('currentPage prop', () => { it('should render correctly with currentPage prop', async () => { - // Arrange const page = createMockNotionPage({ page_name: 'My Test Page' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('My Test Page')).toBeInTheDocument() }) it('should render correctly without currentPage prop (undefined)', async () => { - // Arrange & Act await renderNotionPagePreview({ currentPage: undefined }, false) // Assert - Header should still render @@ -548,10 +470,8 @@ describe('NotionPagePreview', () => { }) it('should handle page with empty name', async () => { - // Arrange const page = createMockNotionPage({ page_name: '' }) - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - Should not crash @@ -559,52 +479,40 @@ describe('NotionPagePreview', () => { }) it('should handle page with very long name', async () => { - // Arrange const longName = 'a'.repeat(200) const page = createMockNotionPage({ page_name: longName }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle page with special characters in name', async () => { - // Arrange const page = createMockNotionPage({ page_name: 'Page with & "chars"' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('Page with & "chars"')).toBeInTheDocument() }) it('should handle page with unicode characters in name', async () => { - // Arrange const page = createMockNotionPage({ page_name: 'äž­æ–‡éĄ”éąćç§° 🚀 æ—„æœŹèȘž' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('äž­æ–‡éĄ”éąćç§° 🚀 æ—„æœŹèȘž')).toBeInTheDocument() }) }) describe('notionCredentialId prop', () => { it('should pass notionCredentialId to API call', async () => { - // Arrange const page = createMockNotionPage() - // Act await renderNotionPagePreview({ currentPage: page, notionCredentialId: 'my-credential-id', }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ credentialID: 'my-credential-id' }), ) @@ -613,10 +521,8 @@ describe('NotionPagePreview', () => { describe('hidePreview prop', () => { it('should accept hidePreview callback', async () => { - // Arrange const hidePreview = vi.fn() - // Act await renderNotionPagePreview({ hidePreview }) // Assert - No errors thrown @@ -625,15 +531,10 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle page with undefined page_id', async () => { - // Arrange const page = createMockNotionPage({ page_id: undefined as unknown as string }) - // Act await renderNotionPagePreview({ currentPage: page }) // Assert - API should still be called (with undefined pageID) @@ -641,36 +542,28 @@ describe('NotionPagePreview', () => { }) it('should handle page with empty string page_id', async () => { - // Arrange const page = createMockNotionPage({ page_id: '' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ pageID: '' }), ) }) it('should handle very long preview content', async () => { - // Arrange const longContent = 'x'.repeat(10000) mockFetchNotionPagePreview.mockResolvedValue({ content: longContent }) - // Act await renderNotionPagePreview() - // Assert expect(screen.getByText(longContent)).toBeInTheDocument() }) it('should handle preview content with special characters safely', async () => { - // Arrange const specialContent = '\n\t& < > "' mockFetchNotionPagePreview.mockResolvedValue({ content: specialContent }) - // Act const { container } = await renderNotionPagePreview() // Assert - Should render as text, not execute scripts @@ -680,26 +573,20 @@ describe('NotionPagePreview', () => { }) it('should handle preview content with unicode', async () => { - // Arrange const unicodeContent = 'äž­æ–‡ć†…ćźč 🚀 Ă©mojis & spĂ«cĂźal çhĂ rs' mockFetchNotionPagePreview.mockResolvedValue({ content: unicodeContent }) - // Act await renderNotionPagePreview() - // Assert expect(screen.getByText(unicodeContent)).toBeInTheDocument() }) it('should handle preview content with newlines', async () => { - // Arrange const multilineContent = 'Line 1\nLine 2\nLine 3' mockFetchNotionPagePreview.mockResolvedValue({ content: multilineContent }) - // Act const { container } = await renderNotionPagePreview() - // Assert const contentDiv = container.querySelector('[class*="fileContent"]') expect(contentDiv).toBeInTheDocument() expect(contentDiv?.textContent).toContain('Line 1') @@ -708,10 +595,8 @@ describe('NotionPagePreview', () => { }) it('should handle null content from API', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: null as unknown as string }) - // Act const { container } = await renderNotionPagePreview() // Assert - Should not crash @@ -719,29 +604,22 @@ describe('NotionPagePreview', () => { }) it('should handle different page types', async () => { - // Arrange const databasePage = createMockNotionPage({ type: 'database' }) - // Act await renderNotionPagePreview({ currentPage: databasePage }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ pageType: 'database' }), ) }) }) - // -------------------------------------------------------------------------- // Side Effects and Cleanup Tests - // -------------------------------------------------------------------------- describe('Side Effects and Cleanup', () => { it('should trigger effect when currentPage prop changes', async () => { - // Arrange const page1 = createMockNotionPage({ page_id: 'page-1' }) const page2 = createMockNotionPage({ page_id: 'page-2' }) - // Act const { rerender } = render( , ) @@ -754,19 +632,16 @@ describe('NotionPagePreview', () => { rerender() }) - // Assert await waitFor(() => { expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(2) }) }) it('should not trigger effect when hidePreview changes', async () => { - // Arrange const page = createMockNotionPage() const hidePreview1 = vi.fn() const hidePreview2 = vi.fn() - // Act const { rerender } = render( , ) @@ -785,10 +660,8 @@ describe('NotionPagePreview', () => { }) it('should not trigger effect when notionCredentialId changes', async () => { - // Arrange const page = createMockNotionPage() - // Act const { rerender } = render( , ) @@ -806,11 +679,9 @@ describe('NotionPagePreview', () => { }) it('should handle rapid page changes', async () => { - // Arrange const pages = Array.from({ length: 5 }, (_, i) => createMockNotionPage({ page_id: `page-${i}` })) - // Act const { rerender } = render( , ) @@ -829,7 +700,6 @@ describe('NotionPagePreview', () => { }) it('should handle unmount during loading', async () => { - // Arrange mockFetchNotionPagePreview.mockImplementation( () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)), ) @@ -845,10 +715,8 @@ describe('NotionPagePreview', () => { }) it('should handle page changing from defined to undefined', async () => { - // Arrange const page = createMockNotionPage() - // Act const { rerender, container } = render( , ) @@ -867,38 +735,27 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- - // Accessibility Tests - // -------------------------------------------------------------------------- describe('Accessibility', () => { it('should have clickable close button with visual indicator', async () => { - // Arrange & Act const { container } = await renderNotionPagePreview() - // Assert const closeButton = container.querySelector('.cursor-pointer') expect(closeButton).toBeInTheDocument() expect(closeButton).toHaveClass('cursor-pointer') }) it('should have proper heading structure', async () => { - // Arrange & Act await renderNotionPagePreview() - // Assert expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Error Handling Tests - // -------------------------------------------------------------------------- describe('Error Handling', () => { it('should not crash on API network error', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('Network Error')) - // Act const { container } = await renderNotionPagePreview({}, false) // Assert - Component should still render @@ -908,122 +765,92 @@ describe('NotionPagePreview', () => { }) it('should not crash on API timeout', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('Timeout')) - // Act const { container } = await renderNotionPagePreview({}, false) - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) }) it('should not crash on malformed API response', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({} as { content: string }) - // Act const { container } = await renderNotionPagePreview() - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle 404 error gracefully', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('404 Not Found')) - // Act const { container } = await renderNotionPagePreview({}, false) - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) }) it('should handle 500 error gracefully', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('500 Internal Server Error')) - // Act const { container } = await renderNotionPagePreview({}, false) - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) }) it('should handle authorization error gracefully', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('401 Unauthorized')) - // Act const { container } = await renderNotionPagePreview({}, false) - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) }) }) - // -------------------------------------------------------------------------- // Page Type Variations Tests - // -------------------------------------------------------------------------- describe('Page Type Variations', () => { it('should handle page type', async () => { - // Arrange const page = createMockNotionPage({ type: 'page' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ pageType: 'page' }), ) }) it('should handle database type', async () => { - // Arrange const page = createMockNotionPage({ type: 'database' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ pageType: 'database' }), ) }) it('should handle unknown type', async () => { - // Arrange const page = createMockNotionPage({ type: 'unknown_type' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ pageType: 'unknown_type' }), ) }) }) - // -------------------------------------------------------------------------- // Icon Type Variations Tests - // -------------------------------------------------------------------------- describe('Icon Type Variations', () => { it('should handle page with null icon', async () => { - // Arrange const page = createMockNotionPage({ page_icon: null }) - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - Should render default icon @@ -1032,31 +859,24 @@ describe('NotionPagePreview', () => { }) it('should handle page with emoji icon object', async () => { - // Arrange const page = createMockNotionPageWithEmojiIcon('📄') - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('📄')).toBeInTheDocument() }) it('should handle page with url icon object', async () => { - // Arrange const page = createMockNotionPageWithUrlIcon('https://example.com/custom-icon.png') - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) - // Assert const img = container.querySelector('img[alt="page icon"]') expect(img).toBeInTheDocument() expect(img).toHaveAttribute('src', 'https://example.com/custom-icon.png') }) it('should handle page with icon object having null values', async () => { - // Arrange const page = createMockNotionPage({ page_icon: { type: null, @@ -1065,7 +885,6 @@ describe('NotionPagePreview', () => { }, }) - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - Should render, likely with default/fallback @@ -1073,7 +892,6 @@ describe('NotionPagePreview', () => { }) it('should handle page with icon object having empty url', async () => { - // Arrange // Suppress console.error for this test as we're intentionally testing empty src edge case const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn()) @@ -1085,7 +903,6 @@ describe('NotionPagePreview', () => { }, }) - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - Component should not crash, may render img or fallback @@ -1100,32 +917,24 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- // Content Display Tests - // -------------------------------------------------------------------------- describe('Content Display', () => { it('should display content in fileContent div with correct class', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: 'Test content' }) - // Act const { container } = await renderNotionPagePreview() - // Assert const contentDiv = container.querySelector('[class*="fileContent"]') expect(contentDiv).toBeInTheDocument() expect(contentDiv).toHaveTextContent('Test content') }) it('should preserve whitespace in content', async () => { - // Arrange const contentWithWhitespace = ' indented content\n more indent' mockFetchNotionPagePreview.mockResolvedValue({ content: contentWithWhitespace }) - // Act const { container } = await renderNotionPagePreview() - // Assert const contentDiv = container.querySelector('[class*="fileContent"]') expect(contentDiv).toBeInTheDocument() // The CSS class has white-space: pre-line @@ -1133,13 +942,10 @@ describe('NotionPagePreview', () => { }) it('should display empty string content without loading', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: '' }) - // Act const { container } = await renderNotionPagePreview() - // Assert const loadingElement = findLoadingSpinner(container) expect(loadingElement).not.toBeInTheDocument() const contentDiv = container.querySelector('[class*="fileContent"]') diff --git a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f00ff121cc --- /dev/null +++ b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx @@ -0,0 +1,561 @@ +import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types' +import type { NotionPage } from '@/models/common' +import type { CrawlOptions, CrawlResultItem, DataSet, FileItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { Plan } from '@/app/components/billing/type' +import { DataSourceType } from '@/models/datasets' +import StepOne from '../index' + +// Mock config for website crawl features +vi.mock('@/config', () => ({ + ENABLE_WEBSITE_FIRECRAWL: true, + ENABLE_WEBSITE_JINAREADER: false, + ENABLE_WEBSITE_WATERCRAWL: false, +})) + +// Mock dataset detail context +let mockDatasetDetail: DataSet | undefined +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | undefined }) => DataSet | undefined) => { + return selector({ dataset: mockDatasetDetail }) + }, +})) + +// Mock provider context +let mockPlan = { + type: Plan.professional, + usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, + total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, +} +let mockEnableBilling = false + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: mockPlan, + enableBilling: mockEnableBilling, + }), +})) + +vi.mock('../../file-uploader', () => ({ + default: ({ onPreview, fileList }: { onPreview: (file: File) => void, fileList: FileItem[] }) => ( +
+ {fileList.length} + +
+ ), +})) + +vi.mock('../../website', () => ({ + default: ({ onPreview }: { onPreview: (item: CrawlResultItem) => void }) => ( +
+ +
+ ), +})) + +vi.mock('../../empty-dataset-creation-modal', () => ({ + default: ({ show, onHide }: { show: boolean, onHide: () => void }) => ( + show + ? ( +
+ +
+ ) + : null + ), +})) + +// NotionConnector is a base component - imported directly without mock +// It only depends on i18n which is globally mocked + +vi.mock('@/app/components/base/notion-page-selector', () => ({ + NotionPageSelector: ({ onPreview }: { onPreview: (page: NotionPage) => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/billing/vector-space-full', () => ({ + default: () =>
Vector Space Full
, +})) + +vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ + default: ({ show, onClose }: { show: boolean, onClose: () => void }) => ( + show + ? ( +
+ +
+ ) + : null + ), +})) + +vi.mock('../../file-preview', () => ({ + default: ({ file, hidePreview }: { file: File, hidePreview: () => void }) => ( +
+ {file.name} + +
+ ), +})) + +vi.mock('../../notion-page-preview', () => ({ + default: ({ currentPage, hidePreview }: { currentPage: NotionPage, hidePreview: () => void }) => ( +
+ {currentPage.page_id} + +
+ ), +})) + +// WebsitePreview is a sibling component without API dependencies - imported directly +// It only depends on i18n which is globally mocked + +vi.mock('../upgrade-card', () => ({ + default: () =>
Upgrade Card
, +})) + +const createMockCustomFile = (overrides: { id?: string, name?: string } = {}) => { + const file = new File(['test content'], overrides.name ?? 'test.txt', { type: 'text/plain' }) + return Object.assign(file, { + id: overrides.id ?? 'uploaded-id', + extension: 'txt', + mime_type: 'text/plain', + created_by: 'user-1', + created_at: Date.now(), + }) +} + +const createMockFileItem = (overrides: Partial = {}): FileItem => ({ + fileID: `file-${Date.now()}`, + file: createMockCustomFile(overrides.file as { id?: string, name?: string }), + progress: 100, + ...overrides, +}) + +const createMockNotionPage = (overrides: Partial = {}): NotionPage => ({ + page_id: `page-${Date.now()}`, + type: 'page', + ...overrides, +} as NotionPage) + +const createMockCrawlResult = (overrides: Partial = {}): CrawlResultItem => ({ + title: 'Test Page', + markdown: 'Test content', + description: 'Test description', + source_url: 'https://example.com', + ...overrides, +}) + +const createMockDataSourceAuth = (overrides: Partial = {}): DataSourceAuth => ({ + credential_id: 'cred-1', + provider: 'notion_datasource', + plugin_id: 'plugin-1', + credentials_list: [{ id: 'cred-1', name: 'Workspace 1' }], + ...overrides, +} as DataSourceAuth) + +const defaultProps = { + dataSourceType: DataSourceType.FILE, + dataSourceTypeDisable: false, + onSetting: vi.fn(), + files: [] as FileItem[], + updateFileList: vi.fn(), + updateFile: vi.fn(), + notionPages: [] as NotionPage[], + notionCredentialId: '', + updateNotionPages: vi.fn(), + updateNotionCredentialId: vi.fn(), + onStepChange: vi.fn(), + changeType: vi.fn(), + websitePages: [] as CrawlResultItem[], + updateWebsitePages: vi.fn(), + onWebsiteCrawlProviderChange: vi.fn(), + onWebsiteCrawlJobIdChange: vi.fn(), + crawlOptions: { + crawl_sub_pages: true, + only_main_content: true, + includes: '', + excludes: '', + limit: 10, + max_depth: '', + use_sitemap: true, + } as CrawlOptions, + onCrawlOptionsChange: vi.fn(), + authedDataSourceList: [] as DataSourceAuth[], +} + +// NOTE: Child component unit tests (usePreviewState, DataSourceTypeSelector, +// NextStepButton, PreviewPanel) have been moved to their own dedicated spec files: +// - ./hooks/use-preview-state.spec.ts +// - ./components/data-source-type-selector.spec.tsx +// - ./components/next-step-button.spec.tsx +// - ./components/preview-panel.spec.tsx +// This file now focuses exclusively on StepOne parent component tests. + +// StepOne Component Tests +describe('StepOne', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDatasetDetail = undefined + mockPlan = { + type: Plan.professional, + usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, + total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, + } + mockEnableBilling = false + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() + }) + + it('should render DataSourceTypeSelector when not editing existing dataset', () => { + render() + + expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument() + }) + + it('should render FileUploader when dataSourceType is FILE', () => { + render() + + expect(screen.getByTestId('file-uploader')).toBeInTheDocument() + }) + + it('should render NotionConnector when dataSourceType is NOTION and not authenticated', () => { + render() + + // Assert - NotionConnector shows sync title and connect button + expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })).toBeInTheDocument() + }) + + it('should render NotionPageSelector when dataSourceType is NOTION and authenticated', () => { + const authedDataSourceList = [createMockDataSourceAuth()] + + render() + + expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument() + }) + + it('should render Website when dataSourceType is WEB', () => { + render() + + expect(screen.getByTestId('website')).toBeInTheDocument() + }) + + it('should render empty dataset creation link when no datasetId', () => { + render() + + expect(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')).toBeInTheDocument() + }) + + it('should not render empty dataset creation link when datasetId exists', () => { + render() + + expect(screen.queryByText('datasetCreation.stepOne.emptyDatasetCreation')).not.toBeInTheDocument() + }) + }) + + // Props Tests + describe('Props', () => { + it('should pass files to FileUploader', () => { + const files = [createMockFileItem()] + + render() + + expect(screen.getByTestId('file-count')).toHaveTextContent('1') + }) + + it('should call onSetting when NotionConnector connect button is clicked', () => { + const onSetting = vi.fn() + render() + + // Act - The NotionConnector's button calls onSetting + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })) + + expect(onSetting).toHaveBeenCalledTimes(1) + }) + + it('should call changeType when data source type is changed', () => { + const changeType = vi.fn() + render() + + fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) + + expect(changeType).toHaveBeenCalledWith(DataSourceType.NOTION) + }) + }) + + describe('State Management', () => { + it('should open empty dataset modal when link is clicked', () => { + render() + + fireEvent.click(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')) + + expect(screen.getByTestId('empty-dataset-modal')).toBeInTheDocument() + }) + + it('should close empty dataset modal when close is clicked', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')) + + fireEvent.click(screen.getByTestId('close-modal')) + + expect(screen.queryByTestId('empty-dataset-modal')).not.toBeInTheDocument() + }) + }) + + describe('Memoization', () => { + it('should correctly compute isNotionAuthed based on authedDataSourceList', () => { + // Arrange - No auth + const { rerender } = render() + // NotionConnector shows the sync title when not authenticated + expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() + + // Act - Add auth + const authedDataSourceList = [createMockDataSourceAuth()] + rerender() + + expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument() + }) + + it('should correctly compute fileNextDisabled when files are empty', () => { + render() + + // Assert - Button should be disabled + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + + it('should correctly compute fileNextDisabled when files are loaded', () => { + const files = [createMockFileItem()] + + render() + + // Assert - Button should be enabled + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + }) + + it('should correctly compute fileNextDisabled when some files are not uploaded', () => { + // Arrange - Create a file item without id (not yet uploaded) + const file = new File(['test'], 'test.txt', { type: 'text/plain' }) + const fileItem: FileItem = { + fileID: 'temp-id', + file: Object.assign(file, { id: undefined, extension: 'txt', mime_type: 'text/plain' }), + progress: 0, + } + + render() + + // Assert - Button should be disabled + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + }) + + describe('Callbacks', () => { + it('should call onStepChange when next button is clicked with valid files', () => { + const onStepChange = vi.fn() + const files = [createMockFileItem()] + render() + + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + expect(onStepChange).toHaveBeenCalledTimes(1) + }) + + it('should show plan upgrade modal when batch upload not supported and multiple files', () => { + mockEnableBilling = true + mockPlan.type = Plan.sandbox + const files = [createMockFileItem(), createMockFileItem()] + render() + + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + }) + + it('should show upgrade card when in sandbox plan with files', () => { + mockEnableBilling = true + mockPlan.type = Plan.sandbox + const files = [createMockFileItem()] + + render() + + expect(screen.getByTestId('upgrade-card')).toBeInTheDocument() + }) + }) + + // Vector Space Full Tests + describe('Vector Space Full', () => { + it('should show VectorSpaceFull when vector space is full and billing is enabled', () => { + mockEnableBilling = true + mockPlan.usage.vectorSpace = 100 + mockPlan.total.vectorSpace = 100 + const files = [createMockFileItem()] + + render() + + expect(screen.getByTestId('vector-space-full')).toBeInTheDocument() + }) + + it('should disable next button when vector space is full', () => { + mockEnableBilling = true + mockPlan.usage.vectorSpace = 100 + mockPlan.total.vectorSpace = 100 + const files = [createMockFileItem()] + + render() + + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + }) + + // Preview Integration Tests + describe('Preview Integration', () => { + it('should show file preview when file preview button is clicked', () => { + render() + + fireEvent.click(screen.getByTestId('preview-file')) + + expect(screen.getByTestId('file-preview')).toBeInTheDocument() + }) + + it('should hide file preview when hide button is clicked', () => { + render() + fireEvent.click(screen.getByTestId('preview-file')) + + fireEvent.click(screen.getByTestId('hide-file-preview')) + + expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument() + }) + + it('should show notion page preview when preview button is clicked', () => { + const authedDataSourceList = [createMockDataSourceAuth()] + render() + + fireEvent.click(screen.getByTestId('preview-notion')) + + expect(screen.getByTestId('notion-page-preview')).toBeInTheDocument() + }) + + it('should show website preview when preview button is clicked', () => { + render() + + fireEvent.click(screen.getByTestId('preview-website')) + + // Assert - Check for pagePreview title which is shown by WebsitePreview + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty notionPages array', () => { + const authedDataSourceList = [createMockDataSourceAuth()] + + render() + + // Assert - Button should be disabled when no pages selected + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + + it('should handle empty websitePages array', () => { + render() + + // Assert - Button should be disabled when no pages crawled + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + + it('should handle empty authedDataSourceList', () => { + render() + + // Assert - Should show NotionConnector with connect button + expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() + }) + + it('should handle authedDataSourceList without notion credentials', () => { + const authedDataSourceList = [createMockDataSourceAuth({ credentials_list: [] })] + + render() + + // Assert - Should show NotionConnector with connect button + expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() + }) + + it('should clear previews when switching data source types', () => { + render() + fireEvent.click(screen.getByTestId('preview-file')) + expect(screen.getByTestId('file-preview')).toBeInTheDocument() + + // Act - Change to NOTION + fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) + + // Assert - File preview should be cleared + expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument() + }) + }) + + describe('Integration', () => { + it('should complete file upload flow', () => { + const onStepChange = vi.fn() + const files = [createMockFileItem()] + + render() + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + expect(onStepChange).toHaveBeenCalled() + }) + + it('should complete notion page selection flow', () => { + const onStepChange = vi.fn() + const authedDataSourceList = [createMockDataSourceAuth()] + const notionPages = [createMockNotionPage()] + + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + expect(onStepChange).toHaveBeenCalled() + }) + + it('should complete website crawl flow', () => { + const onStepChange = vi.fn() + const websitePages = [createMockCrawlResult()] + + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + expect(onStepChange).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx new file mode 100644 index 0000000000..ab7d0f0225 --- /dev/null +++ b/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import UpgradeCard from '../upgrade-card' + +const mockSetShowPricingModal = vi.fn() + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: mockSetShowPricingModal, + }), +})) + +vi.mock('@/app/components/billing/upgrade-btn', () => ({ + default: ({ onClick, className }: { onClick?: () => void, className?: string }) => ( + + ), +})) + +describe('UpgradeCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + // Assert - title and description i18n keys are rendered + expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument() + }) + + it('should render the upgrade title text', () => { + render() + + expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument() + }) + + it('should render the upgrade description text', () => { + render() + + expect(screen.getByText(/uploadMultipleFiles\.description/i)).toBeInTheDocument() + }) + + it('should render the upgrade button', () => { + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call setShowPricingModal when upgrade button is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button')) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should not call setShowPricingModal without user interaction', () => { + render() + + expect(mockSetShowPricingModal).not.toHaveBeenCalled() + }) + + it('should call setShowPricingModal on each button click', () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + fireEvent.click(button) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(2) + }) + }) + + describe('Memoization', () => { + it('should maintain rendering after rerender with same props', () => { + const { rerender } = render() + + rerender() + + expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx b/web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx new file mode 100644 index 0000000000..aeb1afad26 --- /dev/null +++ b/web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' + +// Mock config to control web crawl feature flags +vi.mock('@/config', () => ({ + ENABLE_WEBSITE_FIRECRAWL: true, + ENABLE_WEBSITE_JINAREADER: true, + ENABLE_WEBSITE_WATERCRAWL: false, +})) + +// Mock CSS module +vi.mock('../../../index.module.css', () => ({ + default: { + dataSourceItem: 'ds-item', + active: 'active', + disabled: 'disabled', + datasetIcon: 'icon', + notion: 'notion-icon', + web: 'web-icon', + }, +})) + +const { default: DataSourceTypeSelector } = await import('../data-source-type-selector') + +describe('DataSourceTypeSelector', () => { + const defaultProps = { + currentType: DataSourceType.FILE, + disabled: false, + onChange: vi.fn(), + onClearPreviews: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render file, notion, and web options', () => { + render() + expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.dataSourceType.web')).toBeInTheDocument() + }) + + it('should render as a 3-column grid', () => { + const { container } = render() + expect(container.firstElementChild).toHaveClass('grid-cols-3') + }) + }) + + describe('interactions', () => { + it('should call onChange and onClearPreviews on type click', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) + expect(defaultProps.onChange).toHaveBeenCalledWith(DataSourceType.NOTION) + expect(defaultProps.onClearPreviews).toHaveBeenCalledWith(DataSourceType.NOTION) + }) + + it('should not call onChange when disabled', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) + expect(defaultProps.onChange).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-one/components/__tests__/next-step-button.spec.tsx b/web/app/components/datasets/create/step-one/components/__tests__/next-step-button.spec.tsx new file mode 100644 index 0000000000..58d124d867 --- /dev/null +++ b/web/app/components/datasets/create/step-one/components/__tests__/next-step-button.spec.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import NextStepButton from '../next-step-button' + +describe('NextStepButton', () => { + const defaultProps = { + disabled: false, + onClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render button text', () => { + render() + expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument() + }) + + it('should render a primary variant button', () => { + render() + const btn = screen.getByRole('button') + expect(btn).toBeInTheDocument() + }) + + it('should call onClick when clicked', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(defaultProps.onClick).toHaveBeenCalledOnce() + }) + + it('should be disabled when disabled prop is true', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should not call onClick when disabled', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(defaultProps.onClick).not.toHaveBeenCalled() + }) + + it('should render arrow icon', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx new file mode 100644 index 0000000000..f495dd9f3f --- /dev/null +++ b/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx @@ -0,0 +1,119 @@ +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock child components - paths must match source file's imports (relative to source) +vi.mock('../../../file-preview', () => ({ + default: ({ file, hidePreview }: { file: { name: string }, hidePreview: () => void }) => ( +
+ {file.name} + +
+ ), +})) + +vi.mock('../../../notion-page-preview', () => ({ + default: ({ currentPage, hidePreview }: { currentPage: { page_name: string }, hidePreview: () => void }) => ( +
+ {currentPage.page_name} + +
+ ), +})) + +vi.mock('../../../website/preview', () => ({ + default: ({ payload, hidePreview }: { payload: { title: string }, hidePreview: () => void }) => ( +
+ {payload.title} + +
+ ), +})) + +vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ + default: ({ show, onClose, title }: { show: boolean, onClose: () => void, title: string }) => show + ? ( +
+ {title} + +
+ ) + : null, +})) + +const { default: PreviewPanel } = await import('../preview-panel') + +describe('PreviewPanel', () => { + const defaultProps = { + currentFile: undefined, + currentNotionPage: undefined, + currentWebsite: undefined, + notionCredentialId: 'cred-1', + isShowPlanUpgradeModal: false, + hideFilePreview: vi.fn(), + hideNotionPagePreview: vi.fn(), + hideWebsitePreview: vi.fn(), + hidePlanUpgradeModal: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render nothing when no preview is active', () => { + const { container } = render() + expect(container.querySelector('[data-testid]')).toBeNull() + }) + + it('should render file preview when currentFile is set', () => { + render() + expect(screen.getByTestId('file-preview')).toBeInTheDocument() + expect(screen.getByText('test.pdf')).toBeInTheDocument() + }) + + it('should render notion preview when currentNotionPage is set', () => { + render() + expect(screen.getByTestId('notion-preview')).toBeInTheDocument() + expect(screen.getByText('My Page')).toBeInTheDocument() + }) + + it('should render website preview when currentWebsite is set', () => { + render() + expect(screen.getByTestId('website-preview')).toBeInTheDocument() + expect(screen.getByText('My Site')).toBeInTheDocument() + }) + + it('should render plan upgrade modal when isShowPlanUpgradeModal is true', () => { + render() + expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + }) + }) + + describe('interactions', () => { + it('should call hideFilePreview when file preview close clicked', () => { + render() + fireEvent.click(screen.getByTestId('close-file')) + expect(defaultProps.hideFilePreview).toHaveBeenCalledOnce() + }) + + it('should call hidePlanUpgradeModal when modal close clicked', () => { + render() + fireEvent.click(screen.getByTestId('close-modal')) + expect(defaultProps.hidePlanUpgradeModal).toHaveBeenCalledOnce() + }) + + it('should call hideNotionPagePreview when notion preview close clicked', () => { + render() + fireEvent.click(screen.getByTestId('close-notion')) + expect(defaultProps.hideNotionPagePreview).toHaveBeenCalledOnce() + }) + + it('should call hideWebsitePreview when website preview close clicked', () => { + render() + fireEvent.click(screen.getByTestId('close-website')) + expect(defaultProps.hideWebsitePreview).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts b/web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts new file mode 100644 index 0000000000..9ab71d78e9 --- /dev/null +++ b/web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts @@ -0,0 +1,60 @@ +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import usePreviewState from '../use-preview-state' + +describe('usePreviewState', () => { + it('should initialize with all previews undefined', () => { + const { result } = renderHook(() => usePreviewState()) + + expect(result.current.currentFile).toBeUndefined() + expect(result.current.currentNotionPage).toBeUndefined() + expect(result.current.currentWebsite).toBeUndefined() + }) + + it('should show and hide file preview', () => { + const { result } = renderHook(() => usePreviewState()) + const file = new File(['content'], 'test.pdf') + + act(() => { + result.current.showFilePreview(file) + }) + expect(result.current.currentFile).toBe(file) + + act(() => { + result.current.hideFilePreview() + }) + expect(result.current.currentFile).toBeUndefined() + }) + + it('should show and hide notion page preview', () => { + const { result } = renderHook(() => usePreviewState()) + const page = { page_id: 'p1', page_name: 'Test' } as unknown as NotionPage + + act(() => { + result.current.showNotionPagePreview(page) + }) + expect(result.current.currentNotionPage).toBe(page) + + act(() => { + result.current.hideNotionPagePreview() + }) + expect(result.current.currentNotionPage).toBeUndefined() + }) + + it('should show and hide website preview', () => { + const { result } = renderHook(() => usePreviewState()) + const website = { title: 'Example', source_url: 'https://example.com' } as unknown as CrawlResultItem + + act(() => { + result.current.showWebsitePreview(website) + }) + expect(result.current.currentWebsite).toBe(website) + + act(() => { + result.current.hideWebsitePreview() + }) + expect(result.current.currentWebsite).toBeUndefined() + }) +}) diff --git a/web/app/components/datasets/create/step-one/index.spec.tsx b/web/app/components/datasets/create/step-one/index.spec.tsx deleted file mode 100644 index 1ff77dc1f6..0000000000 --- a/web/app/components/datasets/create/step-one/index.spec.tsx +++ /dev/null @@ -1,1204 +0,0 @@ -import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types' -import type { NotionPage } from '@/models/common' -import type { CrawlOptions, CrawlResultItem, DataSet, FileItem } from '@/models/datasets' -import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' -import { Plan } from '@/app/components/billing/type' -import { DataSourceType } from '@/models/datasets' -import { DataSourceTypeSelector, NextStepButton, PreviewPanel } from './components' -import { usePreviewState } from './hooks' -import StepOne from './index' - -// ========================================== -// Mock External Dependencies -// ========================================== - -// Mock config for website crawl features -vi.mock('@/config', () => ({ - ENABLE_WEBSITE_FIRECRAWL: true, - ENABLE_WEBSITE_JINAREADER: false, - ENABLE_WEBSITE_WATERCRAWL: false, -})) - -// Mock dataset detail context -let mockDatasetDetail: DataSet | undefined -vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | undefined }) => DataSet | undefined) => { - return selector({ dataset: mockDatasetDetail }) - }, -})) - -// Mock provider context -let mockPlan = { - type: Plan.professional, - usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, - total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, -} -let mockEnableBilling = false - -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => ({ - plan: mockPlan, - enableBilling: mockEnableBilling, - }), -})) - -// Mock child components -vi.mock('../file-uploader', () => ({ - default: ({ onPreview, fileList }: { onPreview: (file: File) => void, fileList: FileItem[] }) => ( -
- {fileList.length} - -
- ), -})) - -vi.mock('../website', () => ({ - default: ({ onPreview }: { onPreview: (item: CrawlResultItem) => void }) => ( -
- -
- ), -})) - -vi.mock('../empty-dataset-creation-modal', () => ({ - default: ({ show, onHide }: { show: boolean, onHide: () => void }) => ( - show - ? ( -
- -
- ) - : null - ), -})) - -// NotionConnector is a base component - imported directly without mock -// It only depends on i18n which is globally mocked - -vi.mock('@/app/components/base/notion-page-selector', () => ({ - NotionPageSelector: ({ onPreview }: { onPreview: (page: NotionPage) => void }) => ( -
- -
- ), -})) - -vi.mock('@/app/components/billing/vector-space-full', () => ({ - default: () =>
Vector Space Full
, -})) - -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: ({ show, onClose }: { show: boolean, onClose: () => void }) => ( - show - ? ( -
- -
- ) - : null - ), -})) - -vi.mock('../file-preview', () => ({ - default: ({ file, hidePreview }: { file: File, hidePreview: () => void }) => ( -
- {file.name} - -
- ), -})) - -vi.mock('../notion-page-preview', () => ({ - default: ({ currentPage, hidePreview }: { currentPage: NotionPage, hidePreview: () => void }) => ( -
- {currentPage.page_id} - -
- ), -})) - -// WebsitePreview is a sibling component without API dependencies - imported directly -// It only depends on i18n which is globally mocked - -vi.mock('./upgrade-card', () => ({ - default: () =>
Upgrade Card
, -})) - -// ========================================== -// Test Data Builders -// ========================================== - -const createMockCustomFile = (overrides: { id?: string, name?: string } = {}) => { - const file = new File(['test content'], overrides.name ?? 'test.txt', { type: 'text/plain' }) - return Object.assign(file, { - id: overrides.id ?? 'uploaded-id', - extension: 'txt', - mime_type: 'text/plain', - created_by: 'user-1', - created_at: Date.now(), - }) -} - -const createMockFileItem = (overrides: Partial = {}): FileItem => ({ - fileID: `file-${Date.now()}`, - file: createMockCustomFile(overrides.file as { id?: string, name?: string }), - progress: 100, - ...overrides, -}) - -const createMockNotionPage = (overrides: Partial = {}): NotionPage => ({ - page_id: `page-${Date.now()}`, - type: 'page', - ...overrides, -} as NotionPage) - -const createMockCrawlResult = (overrides: Partial = {}): CrawlResultItem => ({ - title: 'Test Page', - markdown: 'Test content', - description: 'Test description', - source_url: 'https://example.com', - ...overrides, -}) - -const createMockDataSourceAuth = (overrides: Partial = {}): DataSourceAuth => ({ - credential_id: 'cred-1', - provider: 'notion_datasource', - plugin_id: 'plugin-1', - credentials_list: [{ id: 'cred-1', name: 'Workspace 1' }], - ...overrides, -} as DataSourceAuth) - -const defaultProps = { - dataSourceType: DataSourceType.FILE, - dataSourceTypeDisable: false, - onSetting: vi.fn(), - files: [] as FileItem[], - updateFileList: vi.fn(), - updateFile: vi.fn(), - notionPages: [] as NotionPage[], - notionCredentialId: '', - updateNotionPages: vi.fn(), - updateNotionCredentialId: vi.fn(), - onStepChange: vi.fn(), - changeType: vi.fn(), - websitePages: [] as CrawlResultItem[], - updateWebsitePages: vi.fn(), - onWebsiteCrawlProviderChange: vi.fn(), - onWebsiteCrawlJobIdChange: vi.fn(), - crawlOptions: { - crawl_sub_pages: true, - only_main_content: true, - includes: '', - excludes: '', - limit: 10, - max_depth: '', - use_sitemap: true, - } as CrawlOptions, - onCrawlOptionsChange: vi.fn(), - authedDataSourceList: [] as DataSourceAuth[], -} - -// ========================================== -// usePreviewState Hook Tests -// ========================================== -describe('usePreviewState Hook', () => { - // -------------------------------------------------------------------------- - // Initial State Tests - // -------------------------------------------------------------------------- - describe('Initial State', () => { - it('should initialize with all preview states undefined', () => { - // Arrange & Act - const { result } = renderHook(() => usePreviewState()) - - // Assert - expect(result.current.currentFile).toBeUndefined() - expect(result.current.currentNotionPage).toBeUndefined() - expect(result.current.currentWebsite).toBeUndefined() - }) - }) - - // -------------------------------------------------------------------------- - // File Preview Tests - // -------------------------------------------------------------------------- - describe('File Preview', () => { - it('should show file preview when showFilePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockFile = new File(['test'], 'test.txt') - - // Act - act(() => { - result.current.showFilePreview(mockFile) - }) - - // Assert - expect(result.current.currentFile).toBe(mockFile) - }) - - it('should hide file preview when hideFilePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockFile = new File(['test'], 'test.txt') - - act(() => { - result.current.showFilePreview(mockFile) - }) - - // Act - act(() => { - result.current.hideFilePreview() - }) - - // Assert - expect(result.current.currentFile).toBeUndefined() - }) - }) - - // -------------------------------------------------------------------------- - // Notion Page Preview Tests - // -------------------------------------------------------------------------- - describe('Notion Page Preview', () => { - it('should show notion page preview when showNotionPagePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockPage = createMockNotionPage() - - // Act - act(() => { - result.current.showNotionPagePreview(mockPage) - }) - - // Assert - expect(result.current.currentNotionPage).toBe(mockPage) - }) - - it('should hide notion page preview when hideNotionPagePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockPage = createMockNotionPage() - - act(() => { - result.current.showNotionPagePreview(mockPage) - }) - - // Act - act(() => { - result.current.hideNotionPagePreview() - }) - - // Assert - expect(result.current.currentNotionPage).toBeUndefined() - }) - }) - - // -------------------------------------------------------------------------- - // Website Preview Tests - // -------------------------------------------------------------------------- - describe('Website Preview', () => { - it('should show website preview when showWebsitePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockWebsite = createMockCrawlResult() - - // Act - act(() => { - result.current.showWebsitePreview(mockWebsite) - }) - - // Assert - expect(result.current.currentWebsite).toBe(mockWebsite) - }) - - it('should hide website preview when hideWebsitePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockWebsite = createMockCrawlResult() - - act(() => { - result.current.showWebsitePreview(mockWebsite) - }) - - // Act - act(() => { - result.current.hideWebsitePreview() - }) - - // Assert - expect(result.current.currentWebsite).toBeUndefined() - }) - }) - - // -------------------------------------------------------------------------- - // Callback Stability Tests (Memoization) - // -------------------------------------------------------------------------- - describe('Callback Stability', () => { - it('should maintain stable showFilePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.showFilePreview - - // Act - rerender() - - // Assert - expect(result.current.showFilePreview).toBe(initialCallback) - }) - - it('should maintain stable hideFilePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.hideFilePreview - - // Act - rerender() - - // Assert - expect(result.current.hideFilePreview).toBe(initialCallback) - }) - - it('should maintain stable showNotionPagePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.showNotionPagePreview - - // Act - rerender() - - // Assert - expect(result.current.showNotionPagePreview).toBe(initialCallback) - }) - - it('should maintain stable hideNotionPagePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.hideNotionPagePreview - - // Act - rerender() - - // Assert - expect(result.current.hideNotionPagePreview).toBe(initialCallback) - }) - - it('should maintain stable showWebsitePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.showWebsitePreview - - // Act - rerender() - - // Assert - expect(result.current.showWebsitePreview).toBe(initialCallback) - }) - - it('should maintain stable hideWebsitePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.hideWebsitePreview - - // Act - rerender() - - // Assert - expect(result.current.hideWebsitePreview).toBe(initialCallback) - }) - }) -}) - -// ========================================== -// DataSourceTypeSelector Component Tests -// ========================================== -describe('DataSourceTypeSelector', () => { - const defaultSelectorProps = { - currentType: DataSourceType.FILE, - disabled: false, - onChange: vi.fn(), - onClearPreviews: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- - describe('Rendering', () => { - it('should render all data source options when web is enabled', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument() - expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument() - expect(screen.getByText('datasetCreation.stepOne.dataSourceType.web')).toBeInTheDocument() - }) - - it('should highlight active type', () => { - // Arrange & Act - const { container } = render( - , - ) - - // Assert - The active item should have the active class - const items = container.querySelectorAll('[class*="dataSourceItem"]') - expect(items.length).toBeGreaterThan(0) - }) - }) - - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- - describe('User Interactions', () => { - it('should call onChange when a type is clicked', () => { - // Arrange - const onChange = vi.fn() - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) - - // Assert - expect(onChange).toHaveBeenCalledWith(DataSourceType.NOTION) - }) - - it('should call onClearPreviews when a type is clicked', () => { - // Arrange - const onClearPreviews = vi.fn() - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.web')) - - // Assert - expect(onClearPreviews).toHaveBeenCalledWith(DataSourceType.WEB) - }) - - it('should not call onChange when disabled', () => { - // Arrange - const onChange = vi.fn() - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) - - // Assert - expect(onChange).not.toHaveBeenCalled() - }) - - it('should not call onClearPreviews when disabled', () => { - // Arrange - const onClearPreviews = vi.fn() - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) - - // Assert - expect(onClearPreviews).not.toHaveBeenCalled() - }) - }) -}) - -// ========================================== -// NextStepButton Component Tests -// ========================================== -describe('NextStepButton', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- - describe('Rendering', () => { - it('should render with correct label', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument() - }) - - it('should render with arrow icon', () => { - // Arrange & Act - const { container } = render() - - // Assert - const svgIcon = container.querySelector('svg') - expect(svgIcon).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Props Tests - // -------------------------------------------------------------------------- - describe('Props', () => { - it('should be disabled when disabled prop is true', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByRole('button')).toBeDisabled() - }) - - it('should be enabled when disabled prop is false', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByRole('button')).not.toBeDisabled() - }) - - it('should call onClick when clicked and not disabled', () => { - // Arrange - const onClick = vi.fn() - render() - - // Act - fireEvent.click(screen.getByRole('button')) - - // Assert - expect(onClick).toHaveBeenCalledTimes(1) - }) - - it('should not call onClick when clicked and disabled', () => { - // Arrange - const onClick = vi.fn() - render() - - // Act - fireEvent.click(screen.getByRole('button')) - - // Assert - expect(onClick).not.toHaveBeenCalled() - }) - }) -}) - -// ========================================== -// PreviewPanel Component Tests -// ========================================== -describe('PreviewPanel', () => { - const defaultPreviewProps = { - currentFile: undefined as File | undefined, - currentNotionPage: undefined as NotionPage | undefined, - currentWebsite: undefined as CrawlResultItem | undefined, - notionCredentialId: 'cred-1', - isShowPlanUpgradeModal: false, - hideFilePreview: vi.fn(), - hideNotionPagePreview: vi.fn(), - hideWebsitePreview: vi.fn(), - hidePlanUpgradeModal: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - // -------------------------------------------------------------------------- - // Conditional Rendering Tests - // -------------------------------------------------------------------------- - describe('Conditional Rendering', () => { - it('should not render FilePreview when currentFile is undefined', () => { - // Arrange & Act - render() - - // Assert - expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument() - }) - - it('should render FilePreview when currentFile is defined', () => { - // Arrange - const file = new File(['test'], 'test.txt') - - // Act - render() - - // Assert - expect(screen.getByTestId('file-preview')).toBeInTheDocument() - }) - - it('should not render NotionPagePreview when currentNotionPage is undefined', () => { - // Arrange & Act - render() - - // Assert - expect(screen.queryByTestId('notion-page-preview')).not.toBeInTheDocument() - }) - - it('should render NotionPagePreview when currentNotionPage is defined', () => { - // Arrange - const page = createMockNotionPage() - - // Act - render() - - // Assert - expect(screen.getByTestId('notion-page-preview')).toBeInTheDocument() - }) - - it('should not render WebsitePreview when currentWebsite is undefined', () => { - // Arrange & Act - render() - - // Assert - pagePreview is the title shown in WebsitePreview - expect(screen.queryByText('datasetCreation.stepOne.pagePreview')).not.toBeInTheDocument() - }) - - it('should render WebsitePreview when currentWebsite is defined', () => { - // Arrange - const website = createMockCrawlResult() - - // Act - render() - - // Assert - Check for the preview title and source URL - expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() - expect(screen.getByText(website.source_url)).toBeInTheDocument() - }) - - it('should not render PlanUpgradeModal when isShowPlanUpgradeModal is false', () => { - // Arrange & Act - render() - - // Assert - expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument() - }) - - it('should render PlanUpgradeModal when isShowPlanUpgradeModal is true', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Event Handler Tests - // -------------------------------------------------------------------------- - describe('Event Handlers', () => { - it('should call hideFilePreview when file preview close is clicked', () => { - // Arrange - const hideFilePreview = vi.fn() - const file = new File(['test'], 'test.txt') - render() - - // Act - fireEvent.click(screen.getByTestId('hide-file-preview')) - - // Assert - expect(hideFilePreview).toHaveBeenCalledTimes(1) - }) - - it('should call hideNotionPagePreview when notion preview close is clicked', () => { - // Arrange - const hideNotionPagePreview = vi.fn() - const page = createMockNotionPage() - render() - - // Act - fireEvent.click(screen.getByTestId('hide-notion-preview')) - - // Assert - expect(hideNotionPagePreview).toHaveBeenCalledTimes(1) - }) - - it('should call hideWebsitePreview when website preview close is clicked', () => { - // Arrange - const hideWebsitePreview = vi.fn() - const website = createMockCrawlResult() - const { container } = render() - - // Act - Find the close button (div with cursor-pointer class containing the XMarkIcon) - const closeButton = container.querySelector('.cursor-pointer') - expect(closeButton).toBeInTheDocument() - fireEvent.click(closeButton!) - - // Assert - expect(hideWebsitePreview).toHaveBeenCalledTimes(1) - }) - - it('should call hidePlanUpgradeModal when modal close is clicked', () => { - // Arrange - const hidePlanUpgradeModal = vi.fn() - render() - - // Act - fireEvent.click(screen.getByTestId('close-upgrade-modal')) - - // Assert - expect(hidePlanUpgradeModal).toHaveBeenCalledTimes(1) - }) - }) -}) - -// ========================================== -// StepOne Component Tests -// ========================================== -describe('StepOne', () => { - beforeEach(() => { - vi.clearAllMocks() - mockDatasetDetail = undefined - mockPlan = { - type: Plan.professional, - usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, - total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, - } - mockEnableBilling = false - }) - - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- - describe('Rendering', () => { - it('should render without crashing', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() - }) - - it('should render DataSourceTypeSelector when not editing existing dataset', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument() - }) - - it('should render FileUploader when dataSourceType is FILE', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByTestId('file-uploader')).toBeInTheDocument() - }) - - it('should render NotionConnector when dataSourceType is NOTION and not authenticated', () => { - // Arrange & Act - render() - - // Assert - NotionConnector shows sync title and connect button - expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })).toBeInTheDocument() - }) - - it('should render NotionPageSelector when dataSourceType is NOTION and authenticated', () => { - // Arrange - const authedDataSourceList = [createMockDataSourceAuth()] - - // Act - render() - - // Assert - expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument() - }) - - it('should render Website when dataSourceType is WEB', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByTestId('website')).toBeInTheDocument() - }) - - it('should render empty dataset creation link when no datasetId', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')).toBeInTheDocument() - }) - - it('should not render empty dataset creation link when datasetId exists', () => { - // Arrange & Act - render() - - // Assert - expect(screen.queryByText('datasetCreation.stepOne.emptyDatasetCreation')).not.toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Props Tests - // -------------------------------------------------------------------------- - describe('Props', () => { - it('should pass files to FileUploader', () => { - // Arrange - const files = [createMockFileItem()] - - // Act - render() - - // Assert - expect(screen.getByTestId('file-count')).toHaveTextContent('1') - }) - - it('should call onSetting when NotionConnector connect button is clicked', () => { - // Arrange - const onSetting = vi.fn() - render() - - // Act - The NotionConnector's button calls onSetting - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })) - - // Assert - expect(onSetting).toHaveBeenCalledTimes(1) - }) - - it('should call changeType when data source type is changed', () => { - // Arrange - const changeType = vi.fn() - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) - - // Assert - expect(changeType).toHaveBeenCalledWith(DataSourceType.NOTION) - }) - }) - - // -------------------------------------------------------------------------- - // State Management Tests - // -------------------------------------------------------------------------- - describe('State Management', () => { - it('should open empty dataset modal when link is clicked', () => { - // Arrange - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')) - - // Assert - expect(screen.getByTestId('empty-dataset-modal')).toBeInTheDocument() - }) - - it('should close empty dataset modal when close is clicked', () => { - // Arrange - render() - fireEvent.click(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')) - - // Act - fireEvent.click(screen.getByTestId('close-modal')) - - // Assert - expect(screen.queryByTestId('empty-dataset-modal')).not.toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- - describe('Memoization', () => { - it('should correctly compute isNotionAuthed based on authedDataSourceList', () => { - // Arrange - No auth - const { rerender } = render() - // NotionConnector shows the sync title when not authenticated - expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() - - // Act - Add auth - const authedDataSourceList = [createMockDataSourceAuth()] - rerender() - - // Assert - expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument() - }) - - it('should correctly compute fileNextDisabled when files are empty', () => { - // Arrange & Act - render() - - // Assert - Button should be disabled - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - }) - - it('should correctly compute fileNextDisabled when files are loaded', () => { - // Arrange - const files = [createMockFileItem()] - - // Act - render() - - // Assert - Button should be enabled - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() - }) - - it('should correctly compute fileNextDisabled when some files are not uploaded', () => { - // Arrange - Create a file item without id (not yet uploaded) - const file = new File(['test'], 'test.txt', { type: 'text/plain' }) - const fileItem: FileItem = { - fileID: 'temp-id', - file: Object.assign(file, { id: undefined, extension: 'txt', mime_type: 'text/plain' }), - progress: 0, - } - - // Act - render() - - // Assert - Button should be disabled - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - }) - }) - - // -------------------------------------------------------------------------- - // Callback Tests - // -------------------------------------------------------------------------- - describe('Callbacks', () => { - it('should call onStepChange when next button is clicked with valid files', () => { - // Arrange - const onStepChange = vi.fn() - const files = [createMockFileItem()] - render() - - // Act - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - - // Assert - expect(onStepChange).toHaveBeenCalledTimes(1) - }) - - it('should show plan upgrade modal when batch upload not supported and multiple files', () => { - // Arrange - mockEnableBilling = true - mockPlan.type = Plan.sandbox - const files = [createMockFileItem(), createMockFileItem()] - render() - - // Act - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - - // Assert - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() - }) - - it('should show upgrade card when in sandbox plan with files', () => { - // Arrange - mockEnableBilling = true - mockPlan.type = Plan.sandbox - const files = [createMockFileItem()] - - // Act - render() - - // Assert - expect(screen.getByTestId('upgrade-card')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Vector Space Full Tests - // -------------------------------------------------------------------------- - describe('Vector Space Full', () => { - it('should show VectorSpaceFull when vector space is full and billing is enabled', () => { - // Arrange - mockEnableBilling = true - mockPlan.usage.vectorSpace = 100 - mockPlan.total.vectorSpace = 100 - const files = [createMockFileItem()] - - // Act - render() - - // Assert - expect(screen.getByTestId('vector-space-full')).toBeInTheDocument() - }) - - it('should disable next button when vector space is full', () => { - // Arrange - mockEnableBilling = true - mockPlan.usage.vectorSpace = 100 - mockPlan.total.vectorSpace = 100 - const files = [createMockFileItem()] - - // Act - render() - - // Assert - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - }) - }) - - // -------------------------------------------------------------------------- - // Preview Integration Tests - // -------------------------------------------------------------------------- - describe('Preview Integration', () => { - it('should show file preview when file preview button is clicked', () => { - // Arrange - render() - - // Act - fireEvent.click(screen.getByTestId('preview-file')) - - // Assert - expect(screen.getByTestId('file-preview')).toBeInTheDocument() - }) - - it('should hide file preview when hide button is clicked', () => { - // Arrange - render() - fireEvent.click(screen.getByTestId('preview-file')) - - // Act - fireEvent.click(screen.getByTestId('hide-file-preview')) - - // Assert - expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument() - }) - - it('should show notion page preview when preview button is clicked', () => { - // Arrange - const authedDataSourceList = [createMockDataSourceAuth()] - render() - - // Act - fireEvent.click(screen.getByTestId('preview-notion')) - - // Assert - expect(screen.getByTestId('notion-page-preview')).toBeInTheDocument() - }) - - it('should show website preview when preview button is clicked', () => { - // Arrange - render() - - // Act - fireEvent.click(screen.getByTestId('preview-website')) - - // Assert - Check for pagePreview title which is shown by WebsitePreview - expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Edge Cases - // -------------------------------------------------------------------------- - describe('Edge Cases', () => { - it('should handle empty notionPages array', () => { - // Arrange - const authedDataSourceList = [createMockDataSourceAuth()] - - // Act - render() - - // Assert - Button should be disabled when no pages selected - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - }) - - it('should handle empty websitePages array', () => { - // Arrange & Act - render() - - // Assert - Button should be disabled when no pages crawled - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - }) - - it('should handle empty authedDataSourceList', () => { - // Arrange & Act - render() - - // Assert - Should show NotionConnector with connect button - expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() - }) - - it('should handle authedDataSourceList without notion credentials', () => { - // Arrange - const authedDataSourceList = [createMockDataSourceAuth({ credentials_list: [] })] - - // Act - render() - - // Assert - Should show NotionConnector with connect button - expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() - }) - - it('should clear previews when switching data source types', () => { - // Arrange - render() - fireEvent.click(screen.getByTestId('preview-file')) - expect(screen.getByTestId('file-preview')).toBeInTheDocument() - - // Act - Change to NOTION - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) - - // Assert - File preview should be cleared - expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Integration Tests - // -------------------------------------------------------------------------- - describe('Integration', () => { - it('should complete file upload flow', () => { - // Arrange - const onStepChange = vi.fn() - const files = [createMockFileItem()] - - // Act - render() - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - - // Assert - expect(onStepChange).toHaveBeenCalled() - }) - - it('should complete notion page selection flow', () => { - // Arrange - const onStepChange = vi.fn() - const authedDataSourceList = [createMockDataSourceAuth()] - const notionPages = [createMockNotionPage()] - - // Act - render( - , - ) - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - - // Assert - expect(onStepChange).toHaveBeenCalled() - }) - - it('should complete website crawl flow', () => { - // Arrange - const onStepChange = vi.fn() - const websitePages = [createMockCrawlResult()] - - // Act - render( - , - ) - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - - // Assert - expect(onStepChange).toHaveBeenCalled() - }) - }) -}) diff --git a/web/app/components/datasets/create/step-three/index.spec.tsx b/web/app/components/datasets/create/step-three/__tests__/index.spec.tsx similarity index 84% rename from web/app/components/datasets/create/step-three/index.spec.tsx rename to web/app/components/datasets/create/step-three/__tests__/index.spec.tsx index 74c5912a1b..1b64aea60a 100644 --- a/web/app/components/datasets/create/step-three/index.spec.tsx +++ b/web/app/components/datasets/create/step-three/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ -import type { createDocumentResponse, FullDocumentDetail, IconInfo } from '@/models/datasets' +import type { createDocumentResponse, DataSet, FullDocumentDetail, IconInfo } from '@/models/datasets' import { render, screen } from '@testing-library/react' import { RETRIEVE_METHOD } from '@/types/app' -import StepThree from './index' +import StepThree from '../index' // Mock the EmbeddingProcess component since it has complex async logic -vi.mock('../embedding-process', () => ({ +vi.mock('../../embedding-process', () => ({ default: vi.fn(({ datasetId, batchId, documents, indexingType, retrievalMethod }) => (
{datasetId} @@ -98,97 +98,74 @@ const renderStepThree = (props: Partial[0]> = {}) = return render() } -// ============================================================================ // StepThree Component Tests -// ============================================================================ describe('StepThree', () => { beforeEach(() => { vi.clearAllMocks() mockMediaType = 'pc' }) - // -------------------------------------------------------------------------- // Rendering Tests - Verify component renders properly - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() }) it('should render with creation title when datasetId is not provided', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepThree.creationContent')).toBeInTheDocument() }) it('should render with addition title when datasetId is provided', () => { - // Arrange & Act renderStepThree({ datasetId: 'existing-dataset-123', datasetName: 'Existing Dataset', }) - // Assert expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument() expect(screen.queryByText('datasetCreation.stepThree.creationTitle')).not.toBeInTheDocument() }) it('should render label text in creation mode', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByText('datasetCreation.stepThree.label')).toBeInTheDocument() }) it('should render side tip panel on desktop', () => { - // Arrange mockMediaType = 'pc' - // Act renderStepThree() - // Assert expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument() }) it('should not render side tip panel on mobile', () => { - // Arrange mockMediaType = 'mobile' - // Act renderStepThree() - // Assert expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument() expect(screen.queryByText('datasetCreation.stepThree.sideTipContent')).not.toBeInTheDocument() }) it('should render EmbeddingProcess component', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() }) it('should render documentation link with correct href on desktop', () => { - // Arrange mockMediaType = 'pc' - // Act renderStepThree() - // Assert const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore') expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/integrate-knowledge-within-application') expect(link).toHaveAttribute('target', '_blank') @@ -196,70 +173,53 @@ describe('StepThree', () => { }) it('should apply correct container classes', () => { - // Arrange & Act const { container } = renderStepThree() - // Assert const outerDiv = container.firstChild as HTMLElement expect(outerDiv).toHaveClass('flex', 'h-full', 'max-h-full', 'w-full', 'justify-center', 'overflow-y-auto') }) }) - // -------------------------------------------------------------------------- // Props Testing - Test all prop variations - // -------------------------------------------------------------------------- describe('Props', () => { describe('datasetId prop', () => { it('should render creation mode when datasetId is undefined', () => { - // Arrange & Act renderStepThree({ datasetId: undefined }) - // Assert expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() }) it('should render addition mode when datasetId is provided', () => { - // Arrange & Act renderStepThree({ datasetId: 'dataset-123' }) - // Assert expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument() }) it('should pass datasetId to EmbeddingProcess', () => { - // Arrange const datasetId = 'my-dataset-id' - // Act renderStepThree({ datasetId }) - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent(datasetId) }) it('should use creationCache dataset id when datasetId is not provided', () => { - // Arrange const creationCache = createMockCreationCache() - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('dataset-123') }) }) describe('datasetName prop', () => { it('should display datasetName in creation mode', () => { - // Arrange & Act renderStepThree({ datasetName: 'My Custom Dataset' }) - // Assert expect(screen.getByText('My Custom Dataset')).toBeInTheDocument() }) it('should display datasetName in addition mode description', () => { - // Arrange & Act renderStepThree({ datasetId: 'dataset-123', datasetName: 'Existing Dataset Name', @@ -271,45 +231,35 @@ describe('StepThree', () => { }) it('should fallback to creationCache dataset name when datasetName is not provided', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.dataset!.name = 'Cache Dataset Name' - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByText('Cache Dataset Name')).toBeInTheDocument() }) }) describe('indexingType prop', () => { it('should pass indexingType to EmbeddingProcess', () => { - // Arrange & Act renderStepThree({ indexingType: 'high_quality' }) - // Assert expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('high_quality') }) it('should use creationCache indexing_technique when indexingType is not provided', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.dataset!.indexing_technique = 'economy' as any + creationCache.dataset!.indexing_technique = 'economy' as unknown as DataSet['indexing_technique'] - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy') }) it('should prefer creationCache indexing_technique over indexingType prop', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.dataset!.indexing_technique = 'cache_technique' as any + creationCache.dataset!.indexing_technique = 'cache_technique' as unknown as DataSet['indexing_technique'] - // Act renderStepThree({ creationCache, indexingType: 'prop_technique' }) // Assert - creationCache takes precedence @@ -319,60 +269,47 @@ describe('StepThree', () => { describe('retrievalMethod prop', () => { it('should pass retrievalMethod to EmbeddingProcess', () => { - // Arrange & Act renderStepThree({ retrievalMethod: RETRIEVE_METHOD.semantic }) - // Assert expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('semantic_search') }) it('should use creationCache retrieval method when retrievalMethod is not provided', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as any + creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as unknown as DataSet['retrieval_model_dict'] - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('full_text_search') }) }) describe('creationCache prop', () => { it('should pass batchId from creationCache to EmbeddingProcess', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.batch = 'custom-batch-123' - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('custom-batch-123') }) it('should pass documents from creationCache to EmbeddingProcess', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as any + creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as unknown as createDocumentResponse['documents'] - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3') }) it('should use icon_info from creationCache dataset', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.dataset!.icon_info = createMockIconInfo({ icon: '🚀', icon_background: '#FF0000', }) - // Act const { container } = renderStepThree({ creationCache }) // Assert - Check AppIcon component receives correct props @@ -381,7 +318,6 @@ describe('StepThree', () => { }) it('should handle undefined creationCache', () => { - // Arrange & Act renderStepThree({ creationCache: undefined }) // Assert - Should not crash, use fallback values @@ -390,14 +326,12 @@ describe('StepThree', () => { }) it('should handle creationCache with undefined dataset', () => { - // Arrange const creationCache: createDocumentResponse = { dataset: undefined, batch: 'batch-123', documents: [], } - // Act renderStepThree({ creationCache }) // Assert - Should use default icon info @@ -406,12 +340,9 @@ describe('StepThree', () => { }) }) - // -------------------------------------------------------------------------- // Edge Cases Tests - Test null, undefined, empty values and boundaries - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle all props being undefined', () => { - // Arrange & Act renderStepThree({ datasetId: undefined, datasetName: undefined, @@ -426,7 +357,6 @@ describe('StepThree', () => { }) it('should handle empty string datasetId', () => { - // Arrange & Act renderStepThree({ datasetId: '' }) // Assert - Empty string is falsy, should show creation mode @@ -434,7 +364,6 @@ describe('StepThree', () => { }) it('should handle empty string datasetName', () => { - // Arrange & Act renderStepThree({ datasetName: '' }) // Assert - Should not crash @@ -442,23 +371,18 @@ describe('StepThree', () => { }) it('should handle empty documents array in creationCache', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.documents = [] - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0') }) it('should handle creationCache with missing icon_info', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.dataset!.icon_info = undefined as any + creationCache.dataset!.icon_info = undefined as unknown as IconInfo - // Act renderStepThree({ creationCache }) // Assert - Should use default icon info @@ -466,10 +390,8 @@ describe('StepThree', () => { }) it('should handle very long datasetName', () => { - // Arrange const longName = 'A'.repeat(500) - // Act renderStepThree({ datasetName: longName }) // Assert - Should render without crashing @@ -477,10 +399,8 @@ describe('StepThree', () => { }) it('should handle special characters in datasetName', () => { - // Arrange const specialName = 'Dataset & "quotes" \'apostrophe\'' - // Act renderStepThree({ datasetName: specialName }) // Assert - Should render safely as text @@ -488,22 +408,17 @@ describe('StepThree', () => { }) it('should handle unicode characters in datasetName', () => { - // Arrange const unicodeName = 'æ•°æźé›†ćç§° 🚀 Ă©mojis & spĂ«cĂźal çhĂ rs' - // Act renderStepThree({ datasetName: unicodeName }) - // Assert expect(screen.getByText(unicodeName)).toBeInTheDocument() }) it('should handle creationCache with null dataset name', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.dataset!.name = null as any + creationCache.dataset!.name = null as unknown as string - // Act const { container } = renderStepThree({ creationCache }) // Assert - Should not crash @@ -511,13 +426,10 @@ describe('StepThree', () => { }) }) - // -------------------------------------------------------------------------- // Conditional Rendering Tests - Test mode switching behavior - // -------------------------------------------------------------------------- describe('Conditional Rendering', () => { describe('Creation Mode (no datasetId)', () => { it('should show AppIcon component', () => { - // Arrange & Act const { container } = renderStepThree() // Assert - AppIcon should be rendered @@ -526,7 +438,6 @@ describe('StepThree', () => { }) it('should show Divider component', () => { - // Arrange & Act const { container } = renderStepThree() // Assert - Divider should be rendered (it adds hr with specific classes) @@ -535,20 +446,16 @@ describe('StepThree', () => { }) it('should show dataset name input area', () => { - // Arrange const datasetName = 'Test Dataset Name' - // Act renderStepThree({ datasetName }) - // Assert expect(screen.getByText(datasetName)).toBeInTheDocument() }) }) describe('Addition Mode (with datasetId)', () => { it('should not show AppIcon component', () => { - // Arrange & Act renderStepThree({ datasetId: 'dataset-123' }) // Assert - Creation section should not be rendered @@ -556,7 +463,6 @@ describe('StepThree', () => { }) it('should show addition description with dataset name', () => { - // Arrange & Act renderStepThree({ datasetId: 'dataset-123', datasetName: 'My Dataset', @@ -569,10 +475,8 @@ describe('StepThree', () => { describe('Mobile vs Desktop', () => { it('should show side panel on tablet', () => { - // Arrange mockMediaType = 'tablet' - // Act renderStepThree() // Assert - Tablet is not mobile, should show side panel @@ -580,21 +484,16 @@ describe('StepThree', () => { }) it('should not show side panel on mobile', () => { - // Arrange mockMediaType = 'mobile' - // Act renderStepThree() - // Assert expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument() }) it('should render EmbeddingProcess on mobile', () => { - // Arrange mockMediaType = 'mobile' - // Act renderStepThree() // Assert - Main content should still be rendered @@ -603,64 +502,48 @@ describe('StepThree', () => { }) }) - // -------------------------------------------------------------------------- // EmbeddingProcess Integration Tests - Verify correct props are passed - // -------------------------------------------------------------------------- describe('EmbeddingProcess Integration', () => { it('should pass correct datasetId to EmbeddingProcess with datasetId prop', () => { - // Arrange & Act renderStepThree({ datasetId: 'direct-dataset-id' }) - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('direct-dataset-id') }) it('should pass creationCache dataset id when datasetId prop is undefined', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.dataset!.id = 'cache-dataset-id' - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('cache-dataset-id') }) it('should pass empty string for datasetId when both sources are undefined', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('') }) it('should pass batchId from creationCache', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.batch = 'test-batch-456' - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('test-batch-456') }) it('should pass empty string for batchId when creationCache is undefined', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('') }) it('should prefer datasetId prop over creationCache dataset id', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.dataset!.id = 'cache-id' - // Act renderStepThree({ datasetId: 'prop-id', creationCache }) // Assert - datasetId prop takes precedence @@ -668,12 +551,9 @@ describe('StepThree', () => { }) }) - // -------------------------------------------------------------------------- // Icon Rendering Tests - Verify AppIcon behavior - // -------------------------------------------------------------------------- describe('Icon Rendering', () => { it('should use default icon info when creationCache is undefined', () => { - // Arrange & Act const { container } = renderStepThree() // Assert - Default background color should be applied @@ -683,7 +563,6 @@ describe('StepThree', () => { }) it('should use icon_info from creationCache when available', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.dataset!.icon_info = { icon: '🎉', @@ -692,7 +571,6 @@ describe('StepThree', () => { icon_url: '', } - // Act const { container } = renderStepThree({ creationCache }) // Assert - Custom background color should be applied @@ -702,11 +580,9 @@ describe('StepThree', () => { }) it('should use default icon when creationCache dataset icon_info is undefined', () => { - // Arrange const creationCache = createMockCreationCache() - delete (creationCache.dataset as any).icon_info + delete (creationCache.dataset as Partial).icon_info - // Act const { container } = renderStepThree({ creationCache }) // Assert - Component should still render with default icon @@ -714,15 +590,11 @@ describe('StepThree', () => { }) }) - // -------------------------------------------------------------------------- // Layout Tests - Verify correct CSS classes and structure - // -------------------------------------------------------------------------- describe('Layout', () => { it('should have correct outer container classes', () => { - // Arrange & Act const { container } = renderStepThree() - // Assert const outerDiv = container.firstChild as HTMLElement expect(outerDiv).toHaveClass('flex') expect(outerDiv).toHaveClass('h-full') @@ -730,49 +602,37 @@ describe('StepThree', () => { }) it('should have correct inner container classes', () => { - // Arrange & Act const { container } = renderStepThree() - // Assert const innerDiv = container.querySelector('.max-w-\\[960px\\]') expect(innerDiv).toBeInTheDocument() expect(innerDiv).toHaveClass('shrink-0', 'grow') }) it('should have content wrapper with correct max width', () => { - // Arrange & Act const { container } = renderStepThree() - // Assert const contentWrapper = container.querySelector('.max-w-\\[640px\\]') expect(contentWrapper).toBeInTheDocument() }) it('should have side tip panel with correct width on desktop', () => { - // Arrange mockMediaType = 'pc' - // Act const { container } = renderStepThree() - // Assert const sidePanel = container.querySelector('.w-\\[328px\\]') expect(sidePanel).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Accessibility Tests - Verify accessibility features - // -------------------------------------------------------------------------- describe('Accessibility', () => { it('should have correct link attributes for external documentation link', () => { - // Arrange mockMediaType = 'pc' - // Act renderStepThree() - // Assert const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore') expect(link.tagName).toBe('A') expect(link).toHaveAttribute('target', '_blank') @@ -780,35 +640,27 @@ describe('StepThree', () => { }) it('should have semantic heading structure in creation mode', () => { - // Arrange & Act renderStepThree() - // Assert const title = screen.getByText('datasetCreation.stepThree.creationTitle') expect(title).toBeInTheDocument() expect(title.className).toContain('title-2xl-semi-bold') }) it('should have semantic heading structure in addition mode', () => { - // Arrange & Act renderStepThree({ datasetId: 'dataset-123' }) - // Assert const title = screen.getByText('datasetCreation.stepThree.additionTitle') expect(title).toBeInTheDocument() expect(title.className).toContain('title-2xl-semi-bold') }) }) - // -------------------------------------------------------------------------- // Side Panel Tests - Verify side panel behavior - // -------------------------------------------------------------------------- describe('Side Panel', () => { it('should render RiBookOpenLine icon in side panel', () => { - // Arrange mockMediaType = 'pc' - // Act const { container } = renderStepThree() // Assert - Icon should be present in side panel @@ -817,25 +669,19 @@ describe('StepThree', () => { }) it('should have correct side panel section background', () => { - // Arrange mockMediaType = 'pc' - // Act const { container } = renderStepThree() - // Assert const sidePanel = container.querySelector('.bg-background-section') expect(sidePanel).toBeInTheDocument() }) it('should have correct padding for side panel', () => { - // Arrange mockMediaType = 'pc' - // Act const { container } = renderStepThree() - // Assert const sidePanelWrapper = container.querySelector('.pr-8') expect(sidePanelWrapper).toBeInTheDocument() }) diff --git a/web/app/components/datasets/create/step-two/index.spec.tsx b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx similarity index 81% rename from web/app/components/datasets/create/step-two/index.spec.tsx rename to web/app/components/datasets/create/step-two/__tests__/index.spec.tsx index 7145920f60..9a0a9630ea 100644 --- a/web/app/components/datasets/create/step-two/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx @@ -10,12 +10,12 @@ import type { Rules, } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' -import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { PreviewPanel } from './components/preview-panel' -import { StepTwoFooter } from './components/step-two-footer' +import { PreviewPanel } from '../components/preview-panel' +import { StepTwoFooter } from '../components/step-two-footer' import { DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP, @@ -27,15 +27,11 @@ import { useIndexingEstimate, usePreviewState, useSegmentationState, -} from './hooks' -import escape from './hooks/escape' -import unescape from './hooks/unescape' +} from '../hooks' +import escape from '../hooks/escape' +import unescape from '../hooks/unescape' +import StepTwo from '../index' -// ============================================ -// Mock external dependencies -// ============================================ - -// Mock dataset detail context const mockDataset = { id: 'test-dataset-id', doc_form: ChunkingMode.text, @@ -60,10 +56,6 @@ vi.mock('@/context/dataset-detail', () => ({ selector({ dataset: mockCurrentDataset, mutateDatasetRes: mockMutateDatasetRes }), })) -// Note: @/context/i18n is globally mocked in vitest.setup.ts, no need to mock here -// Note: @/hooks/use-breakpoints uses real import - -// Mock model hooks const mockEmbeddingModelList = [ { provider: 'openai', model: 'text-embedding-ada-002' }, { provider: 'cohere', model: 'embed-english-v3.0' }, @@ -99,7 +91,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useDefaultModel: () => ({ data: mockDefaultEmbeddingModel }), })) -// Mock service hooks const mockFetchDefaultProcessRuleMutate = vi.fn() vi.mock('@/service/knowledge/use-create-dataset', () => ({ useFetchDefaultProcessRule: ({ onSuccess }: { onSuccess: (data: { rules: Rules, limits: { indexing_max_segmentation_tokens_length: number } }) => void }) => ({ @@ -170,18 +161,55 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -// Note: @/app/components/base/toast - uses real import (base component) -// Note: @/app/components/datasets/common/check-rerank-model - uses real import -// Note: @/app/components/base/float-right-container - uses real import (base component) +// Enable IS_CE_EDITION to show QA checkbox in tests +vi.mock('@/config', async () => { + const actual = await vi.importActual('@/config') + return { ...actual, IS_CE_EDITION: true } +}) + +// Mock PreviewDocumentPicker to allow testing handlePickerChange +vi.mock('@/app/components/datasets/common/document-picker/preview-document-picker', () => ({ + // eslint-disable-next-line ts/no-explicit-any + default: ({ onChange, value, files }: { onChange: (item: any) => void, value: any, files: any[] }) => ( +
+ {value?.name} + {files?.map((f: { id: string, name: string }) => ( + + ))} +
+ ), +})) -// Mock checkShowMultiModalTip - requires complex model list structure vi.mock('@/app/components/datasets/settings/utils', () => ({ checkShowMultiModalTip: () => false, })) -// ============================================ -// Test data factories -// ============================================ +// Mock complex child components to avoid deep dependency chains when rendering StepTwo +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: ({ onSelect, readonly }: { onSelect?: (val: Record) => void, readonly?: boolean }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ + default: ({ disabled }: { disabled?: boolean }) => ( +
+ Retrieval Config +
+ ), +})) + +vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({ + default: ({ disabled }: { disabled?: boolean }) => ( +
+ Economical Config +
+ ), +})) const createMockFile = (overrides?: Partial): CustomFile => ({ id: 'file-1', @@ -248,9 +276,7 @@ const createMockEstimate = (overrides?: Partial): ...overrides, }) -// ============================================ // Utility Functions Tests (escape/unescape) -// ============================================ describe('escape utility', () => { beforeEach(() => { @@ -371,10 +397,6 @@ describe('unescape utility', () => { }) }) -// ============================================ -// useSegmentationState Hook Tests -// ============================================ - describe('useSegmentationState', () => { beforeEach(() => { vi.clearAllMocks() @@ -713,9 +735,7 @@ describe('useSegmentationState', () => { }) }) -// ============================================ // useIndexingConfig Hook Tests -// ============================================ describe('useIndexingConfig', () => { beforeEach(() => { @@ -887,9 +907,7 @@ describe('useIndexingConfig', () => { }) }) -// ============================================ // usePreviewState Hook Tests -// ============================================ describe('usePreviewState', () => { beforeEach(() => { @@ -1116,9 +1134,7 @@ describe('usePreviewState', () => { }) }) -// ============================================ // useDocumentCreation Hook Tests -// ============================================ describe('useDocumentCreation', () => { beforeEach(() => { @@ -1540,9 +1556,7 @@ describe('useDocumentCreation', () => { }) }) -// ============================================ // useIndexingEstimate Hook Tests -// ============================================ describe('useIndexingEstimate', () => { beforeEach(() => { @@ -1682,9 +1696,7 @@ describe('useIndexingEstimate', () => { }) }) -// ============================================ // StepTwoFooter Component Tests -// ============================================ describe('StepTwoFooter', () => { beforeEach(() => { @@ -1774,9 +1786,7 @@ describe('StepTwoFooter', () => { }) }) -// ============================================ // PreviewPanel Component Tests -// ============================================ describe('PreviewPanel', () => { beforeEach(() => { @@ -1955,10 +1965,6 @@ describe('PreviewPanel', () => { }) }) -// ============================================ -// Edge Cases Tests -// ============================================ - describe('Edge Cases', () => { beforeEach(() => { vi.clearAllMocks() @@ -2072,9 +2078,7 @@ describe('Edge Cases', () => { }) }) -// ============================================ // Integration Scenarios -// ============================================ describe('Integration Scenarios', () => { beforeEach(() => { @@ -2195,3 +2199,357 @@ describe('Integration Scenarios', () => { }) }) }) + +// StepTwo Component Tests + +describe('StepTwo Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCurrentDataset = null + }) + + afterEach(() => { + cleanup() + }) + + const defaultStepTwoProps = { + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + isAPIKeySet: true, + onSetting: vi.fn(), + notionCredentialId: '', + onStepChange: vi.fn(), + } + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + + it('should show general chunking options when not in upload', () => { + render() + // Should render the segmentation section + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + + it('should show footer with Previous and Next buttons', () => { + render() + expect(screen.getByText(/stepTwo\.previousStep/i)).toBeInTheDocument() + expect(screen.getByText(/stepTwo\.nextStep/i)).toBeInTheDocument() + }) + }) + + describe('Initialization', () => { + it('should fetch default process rule when not in setting mode', () => { + render() + expect(mockFetchDefaultProcessRuleMutate).toHaveBeenCalledWith('/datasets/process-rule') + }) + + it('should apply config from rules when in setting mode with document detail', () => { + const docDetail = createMockDocumentDetail() + render( + , + ) + // Should not fetch default rule when isSetting + expect(mockFetchDefaultProcessRuleMutate).not.toHaveBeenCalled() + }) + }) + + describe('User Interactions', () => { + it('should call onStepChange(-1) when Previous button is clicked', () => { + const onStepChange = vi.fn() + render() + fireEvent.click(screen.getByText(/stepTwo\.previousStep/i)) + expect(onStepChange).toHaveBeenCalledWith(-1) + }) + + it('should trigger handleCreate when Next Step button is clicked', async () => { + const onStepChange = vi.fn() + render() + await act(async () => { + fireEvent.click(screen.getByText(/stepTwo\.nextStep/i)) + }) + // handleCreate validates, builds params, and calls executeCreation + // which calls onStepChange(1) on success + expect(onStepChange).toHaveBeenCalledWith(1) + }) + + it('should trigger updatePreview when preview button is clicked', () => { + render() + // GeneralChunkingOptions renders a "Preview Chunk" button + const previewButtons = screen.getAllByText(/stepTwo\.previewChunk/i) + fireEvent.click(previewButtons[0]) + // updatePreview calls estimateHook.fetchEstimate() + // No error means the handler executed successfully + }) + + it('should trigger handleDocFormChange through parent-child option switch', () => { + render() + // ParentChildOptions renders an OptionCard; find the title element and click its parent card + const parentChildTitles = screen.getAllByText(/stepTwo\.parentChild/i) + // The first match is the title; click it to trigger onDocFormChange + fireEvent.click(parentChildTitles[0]) + // handleDocFormChange sets docForm, segmentationType, and resets estimate + }) + }) + + describe('Conditional Rendering', () => { + it('should show options based on currentDataset doc_form', () => { + mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild } + render( + , + ) + // When currentDataset has parentChild doc_form, should show parent-child option + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + + it('should render setting mode with Save/Cancel buttons', () => { + const docDetail = createMockDocumentDetail() + render( + , + ) + expect(screen.getByText(/stepTwo\.save/i)).toBeInTheDocument() + expect(screen.getByText(/stepTwo\.cancel/i)).toBeInTheDocument() + }) + + it('should call onCancel when Cancel button is clicked in setting mode', () => { + const onCancel = vi.fn() + const docDetail = createMockDocumentDetail() + render( + , + ) + fireEvent.click(screen.getByText(/stepTwo\.cancel/i)) + expect(onCancel).toHaveBeenCalled() + }) + + it('should trigger handleCreate (Save) in setting mode', async () => { + const onSave = vi.fn() + const docDetail = createMockDocumentDetail() + render( + , + ) + await act(async () => { + fireEvent.click(screen.getByText(/stepTwo\.save/i)) + }) + // handleCreate → validateParams → buildCreationParams → executeCreation → onSave + expect(onSave).toHaveBeenCalled() + }) + + it('should show both general and parent-child options in create page', () => { + render() + // When isInInit (no datasetId, no isSetting), both options should show + expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument() + }) + + it('should only show parent-child option when dataset has parentChild doc_form', () => { + mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild } + render( + , + ) + // showGeneralOption should be false (parentChild not in [text, qa]) + // showParentChildOption should be true + expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument() + }) + + it('should show general option only when dataset has text doc_form', () => { + mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.text } + render( + , + ) + // showGeneralOption should be true (text is in [text, qa]) + expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument() + }) + }) + + describe('Upload in Dataset', () => { + it('should show general option when in upload with text doc_form', () => { + mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.text } + render( + , + ) + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + + it('should show general option for empty dataset (no doc_form)', () => { + // eslint-disable-next-line ts/no-explicit-any + mockCurrentDataset = { ...mockDataset, doc_form: undefined as any } + render( + , + ) + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + + it('should show both options in empty dataset upload', () => { + // eslint-disable-next-line ts/no-explicit-any + mockCurrentDataset = { ...mockDataset, doc_form: undefined as any } + render( + , + ) + // isUploadInEmptyDataset=true shows both options + expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument() + }) + }) + + describe('Indexing Mode', () => { + it('should render indexing mode section', () => { + render() + // IndexingModeSection renders the index mode title + expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument() + }) + + it('should render embedding model selector when QUALIFIED', () => { + render() + // ModelSelector is mocked and rendered with data-testid + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + }) + + it('should render retrieval method config', () => { + render() + // RetrievalMethodConfig is mocked with data-testid + expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument() + }) + + it('should disable model and retrieval config when datasetId has existing data source', () => { + mockCurrentDataset = { ...mockDataset, data_source_type: DataSourceType.FILE } + render( + , + ) + // isModelAndRetrievalConfigDisabled should be true + const modelSelector = screen.getByTestId('model-selector') + expect(modelSelector).toHaveAttribute('data-readonly', 'true') + }) + }) + + describe('Preview Panel', () => { + it('should render preview panel', () => { + render() + expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument() + }) + + it('should hide document picker in setting mode', () => { + const docDetail = createMockDocumentDetail() + render( + , + ) + // Preview panel should still render + expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument() + }) + }) + + describe('Handler Functions - Uncovered Paths', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCurrentDataset = null + }) + + afterEach(() => { + cleanup() + }) + + it('should switch to QUALIFIED when selecting parentChild in ECONOMICAL mode', async () => { + render() + await vi.waitFor(() => { + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + const parentChildTitles = screen.getAllByText(/stepTwo\.parentChild/i) + fireEvent.click(parentChildTitles[0]) + }) + + it('should open QA confirm dialog and confirm switch when QA selected in ECONOMICAL mode', async () => { + render() + await vi.waitFor(() => { + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + const qaCheckbox = screen.getByText(/stepTwo\.useQALanguage/i) + fireEvent.click(qaCheckbox) + // Dialog should open → click Switch to confirm (triggers handleQAConfirm) + const switchButton = await screen.findByText(/stepTwo\.switch/i) + expect(switchButton).toBeInTheDocument() + fireEvent.click(switchButton) + }) + + it('should close QA confirm dialog when cancel is clicked', async () => { + render() + await vi.waitFor(() => { + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + // Open QA confirm dialog + const qaCheckbox = screen.getByText(/stepTwo\.useQALanguage/i) + fireEvent.click(qaCheckbox) + const dialogCancelButtons = await screen.findAllByText(/stepTwo\.cancel/i) + fireEvent.click(dialogCancelButtons[0]) + }) + + it('should handle picker change when selecting a different file', () => { + const files = [ + createMockFile({ id: 'file-1', name: 'first.pdf', extension: 'pdf' }), + createMockFile({ id: 'file-2', name: 'second.pdf', extension: 'pdf' }), + ] + render() + const pickerButton = screen.getByTestId('picker-file-2') + fireEvent.click(pickerButton) + }) + + it('should show error toast when preview is clicked with maxChunkLength exceeding limit', () => { + // Set a high maxChunkLength via the DOM attribute + document.body.setAttribute('data-public-indexing-max-segmentation-tokens-length', '100') + render() + // The default maxChunkLength (1024) now exceeds the limit (100) + const previewButtons = screen.getAllByText(/stepTwo\.previewChunk/i) + fireEvent.click(previewButtons[0]) + // Restore + document.body.removeAttribute('data-public-indexing-max-segmentation-tokens-length') + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx new file mode 100644 index 0000000000..8d5779fd78 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx @@ -0,0 +1,168 @@ +import type { PreProcessingRule } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode } from '@/models/datasets' +import { GeneralChunkingOptions } from '../general-chunking-options' + +vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({ + default: ({ onSummaryIndexSettingChange }: { onSummaryIndexSettingChange?: (val: Record) => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/config', () => ({ + IS_CE_EDITION: true, +})) + +const ns = 'datasetCreation' + +const createRules = (): PreProcessingRule[] => [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, +] + +const defaultProps = { + segmentIdentifier: '\\n', + maxChunkLength: 500, + overlap: 50, + rules: createRules(), + currentDocForm: ChunkingMode.text, + docLanguage: 'English', + isActive: true, + isInUpload: false, + isNotUploadInEmptyDataset: false, + hasCurrentDatasetDocForm: false, + onSegmentIdentifierChange: vi.fn(), + onMaxChunkLengthChange: vi.fn(), + onOverlapChange: vi.fn(), + onRuleToggle: vi.fn(), + onDocFormChange: vi.fn(), + onDocLanguageChange: vi.fn(), + onPreview: vi.fn(), + onReset: vi.fn(), + locale: 'en', +} + +describe('GeneralChunkingOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render general chunking title', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.general`)).toBeInTheDocument() + }) + + it('should render delimiter, max length and overlap inputs when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.maxLength`)).toBeInTheDocument() + expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0) + }) + + it('should render preprocessing rules as checkboxes', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.removeExtraSpaces`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)).toBeInTheDocument() + }) + + it('should render preview and reset buttons when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.previewChunk`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.reset`)).toBeInTheDocument() + }) + + it('should not render body when not active', () => { + render() + expect(screen.queryByText(`${ns}.stepTwo.separator`)).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onPreview when preview button clicked', () => { + const onPreview = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.previewChunk`)) + expect(onPreview).toHaveBeenCalledOnce() + }) + + it('should call onReset when reset button clicked', () => { + const onReset = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.reset`)) + expect(onReset).toHaveBeenCalledOnce() + }) + + it('should call onRuleToggle when rule clicked', () => { + const onRuleToggle = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)) + expect(onRuleToggle).toHaveBeenCalledWith('remove_urls_emails') + }) + + it('should call onDocFormChange with text mode when card switched', () => { + const onDocFormChange = vi.fn() + render() + // OptionCard fires onSwitched which calls onDocFormChange(ChunkingMode.text) + // Since isActive=false, clicking the card triggers the switch + const titleEl = screen.getByText(`${ns}.stepTwo.general`) + fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!) + expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.text) + }) + }) + + describe('QA Mode (CE Edition)', () => { + it('should render QA language checkbox', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.useQALanguage`)).toBeInTheDocument() + }) + + it('should toggle QA mode when checkbox clicked', () => { + const onDocFormChange = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`)) + expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.qa) + }) + + it('should toggle back to text mode from QA mode', () => { + const onDocFormChange = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`)) + expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.text) + }) + + it('should not toggle QA mode when hasCurrentDatasetDocForm is true', () => { + const onDocFormChange = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`)) + expect(onDocFormChange).not.toHaveBeenCalled() + }) + + it('should show QA warning tip when in QA mode', () => { + render() + expect(screen.getAllByText(`${ns}.stepTwo.QATip`).length).toBeGreaterThan(0) + }) + }) + + describe('Summary Index Setting', () => { + it('should render SummaryIndexSetting when showSummaryIndexSetting is true', () => { + render() + expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument() + }) + + it('should not render SummaryIndexSetting when showSummaryIndexSetting is false', () => { + render() + expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument() + }) + + it('should call onSummaryIndexSettingChange', () => { + const onSummaryIndexSettingChange = vi.fn() + render() + fireEvent.click(screen.getByTestId('summary-toggle')) + expect(onSummaryIndexSettingChange).toHaveBeenCalledWith({ enable: true }) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx new file mode 100644 index 0000000000..43a944dcd4 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx @@ -0,0 +1,213 @@ +import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { RetrievalConfig } from '@/types/app' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode } from '@/models/datasets' +import { IndexingType } from '../../hooks' +import { IndexingModeSection } from '../indexing-mode-section' + +vi.mock('next/link', () => ({ + default: ({ children, href, ...props }: { children?: React.ReactNode, href?: string, className?: string }) => {children}, +})) + +// Mock external domain components +vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ + default: ({ onChange, disabled }: { value?: RetrievalConfig, onChange?: (val: Record) => void, disabled?: boolean }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({ + default: ({ disabled }: { value?: RetrievalConfig, onChange?: (val: Record) => void, disabled?: boolean }) => ( +
+ Economical Config +
+ ), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: ({ onSelect, readonly }: { onSelect?: (val: Record) => void, readonly?: boolean }) => ( +
+ +
+ ), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, +})) + +const ns = 'datasetCreation' + +const createDefaultModel = (overrides?: Partial): DefaultModel => ({ + provider: 'openai', + model: 'text-embedding-ada-002', + ...overrides, +}) + +const createRetrievalConfig = (): RetrievalConfig => ({ + search_method: 'semantic_search' as RetrievalConfig['search_method'], + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, +}) + +const defaultProps = { + indexType: IndexingType.QUALIFIED, + hasSetIndexType: false, + docForm: ChunkingMode.text, + embeddingModel: createDefaultModel(), + embeddingModelList: [], + retrievalConfig: createRetrievalConfig(), + showMultiModalTip: false, + isModelAndRetrievalConfigDisabled: false, + isQAConfirmDialogOpen: false, + onIndexTypeChange: vi.fn(), + onEmbeddingModelChange: vi.fn(), + onRetrievalConfigChange: vi.fn(), + onQAConfirmDialogClose: vi.fn(), + onQAConfirmDialogConfirm: vi.fn(), +} + +describe('IndexingModeSection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render index mode title', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.indexMode`)).toBeInTheDocument() + }) + + it('should render qualified option when not locked to economical', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.qualified`)).toBeInTheDocument() + }) + + it('should render economical option when not locked to qualified', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.economical`)).toBeInTheDocument() + }) + + it('should only show qualified option when hasSetIndexType and type is qualified', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.qualified`)).toBeInTheDocument() + expect(screen.queryByText(`${ns}.stepTwo.economical`)).not.toBeInTheDocument() + }) + + it('should only show economical option when hasSetIndexType and type is economical', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.economical`)).toBeInTheDocument() + expect(screen.queryByText(`${ns}.stepTwo.qualified`)).not.toBeInTheDocument() + }) + }) + + describe('Embedding Model', () => { + it('should show model selector when indexType is qualified', () => { + render() + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + }) + + it('should not show model selector when indexType is economical', () => { + render() + expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() + }) + + it('should mark model selector as readonly when disabled', () => { + render() + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-readonly', 'true') + }) + + it('should call onEmbeddingModelChange when model selected', () => { + const onEmbeddingModelChange = vi.fn() + render() + fireEvent.click(screen.getByText('Select Model')) + expect(onEmbeddingModelChange).toHaveBeenCalledWith({ provider: 'openai', model: 'text-embedding-3-small' }) + }) + }) + + describe('Retrieval Config', () => { + it('should show RetrievalMethodConfig when qualified', () => { + render() + expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument() + }) + + it('should show EconomicalRetrievalMethodConfig when economical', () => { + render() + expect(screen.getByTestId('economical-retrieval-config')).toBeInTheDocument() + }) + + it('should call onRetrievalConfigChange from qualified config', () => { + const onRetrievalConfigChange = vi.fn() + render() + fireEvent.click(screen.getByText('Change Retrieval')) + expect(onRetrievalConfigChange).toHaveBeenCalledWith({ search_method: 'updated' }) + }) + }) + + describe('Index Type Switching', () => { + it('should call onIndexTypeChange when switching to qualified', () => { + const onIndexTypeChange = vi.fn() + render() + const qualifiedCard = screen.getByText(`${ns}.stepTwo.qualified`).closest('[class*="rounded-xl"]')! + fireEvent.click(qualifiedCard) + expect(onIndexTypeChange).toHaveBeenCalledWith(IndexingType.QUALIFIED) + }) + + it('should disable economical when docForm is QA', () => { + render() + // The economical option card should have disabled styling + const economicalText = screen.getByText(`${ns}.stepTwo.economical`) + const card = economicalText.closest('[class*="rounded-xl"]') + expect(card).toHaveClass('pointer-events-none') + }) + }) + + describe('High Quality Tip', () => { + it('should show high quality tip when qualified is selected and not locked', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.highQualityTip`)).toBeInTheDocument() + }) + + it('should not show high quality tip when index type is locked', () => { + render() + expect(screen.queryByText(`${ns}.stepTwo.highQualityTip`)).not.toBeInTheDocument() + }) + }) + + describe('QA Confirm Dialog', () => { + it('should call onQAConfirmDialogClose when cancel clicked', () => { + const onClose = vi.fn() + render() + const cancelBtns = screen.getAllByText(`${ns}.stepTwo.cancel`) + fireEvent.click(cancelBtns[0]) + expect(onClose).toHaveBeenCalled() + }) + + it('should call onQAConfirmDialogConfirm when confirm clicked', () => { + const onConfirm = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.switch`)) + expect(onConfirm).toHaveBeenCalled() + }) + }) + + describe('Dataset Settings Link', () => { + it('should show settings link when economical and hasSetIndexType', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`).closest('a')).toHaveAttribute('href', '/datasets/ds-123/settings') + }) + + it('should show settings link under model selector when disabled', () => { + render() + const links = screen.getAllByText(`${ns}.stepTwo.datasetSettingLink`) + expect(links.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx new file mode 100644 index 0000000000..e48e87560c --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DelimiterInput, MaxLengthInput, OverlapInput } from '../inputs' + +// i18n mock returns namespaced keys like "datasetCreation.stepTwo.separator" +const ns = 'datasetCreation' + +describe('DelimiterInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render separator label', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument() + }) + + it('should render text input with placeholder', () => { + render() + const input = screen.getByPlaceholderText(`${ns}.stepTwo.separatorPlaceholder`) + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('type', 'text') + }) + + it('should pass through value and onChange props', () => { + const onChange = vi.fn() + render() + expect(screen.getByDisplayValue('test-val')).toBeInTheDocument() + }) + + it('should render tooltip content', () => { + render() + // Tooltip triggers render; component mounts without error + expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument() + }) +}) + +describe('MaxLengthInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render max length label', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.maxLength`)).toBeInTheDocument() + }) + + it('should render number input', () => { + render() + const input = screen.getByRole('spinbutton') + expect(input).toBeInTheDocument() + }) + + it('should accept value prop', () => { + render() + expect(screen.getByDisplayValue('500')).toBeInTheDocument() + }) + + it('should have min of 1', () => { + render() + const input = screen.getByRole('spinbutton') + expect(input).toHaveAttribute('min', '1') + }) +}) + +describe('OverlapInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render overlap label', () => { + render() + expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0) + }) + + it('should render number input', () => { + render() + const input = screen.getByRole('spinbutton') + expect(input).toBeInTheDocument() + }) + + it('should accept value prop', () => { + render() + expect(screen.getByDisplayValue('50')).toBeInTheDocument() + }) + + it('should have min of 1', () => { + render() + const input = screen.getByRole('spinbutton') + expect(input).toHaveAttribute('min', '1') + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx new file mode 100644 index 0000000000..e543efec86 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx @@ -0,0 +1,160 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OptionCard, OptionCardHeader } from '../option-card' + +// Override global next/image auto-mock: tests assert on rendered elements +vi.mock('next/image', () => ({ + default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => ( + {alt} + ), +})) + +describe('OptionCardHeader', () => { + const defaultProps = { + icon: icon, + title: Test Title, + description: 'Test description', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render icon, title and description', () => { + render() + expect(screen.getByTestId('icon')).toBeInTheDocument() + expect(screen.getByText('Test Title')).toBeInTheDocument() + expect(screen.getByText('Test description')).toBeInTheDocument() + }) + + it('should show effect image when active and effectImg provided', () => { + const { container } = render( + , + ) + const img = container.querySelector('img') + expect(img).toBeInTheDocument() + }) + + it('should not show effect image when not active', () => { + const { container } = render( + , + ) + expect(container.querySelector('img')).not.toBeInTheDocument() + }) + + it('should apply cursor-pointer when not disabled', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('cursor-pointer') + }) + + it('should not apply cursor-pointer when disabled', () => { + const { container } = render() + expect(container.firstChild).not.toHaveClass('cursor-pointer') + }) + + it('should apply activeClassName when active', () => { + const { container } = render( + , + ) + expect(container.firstChild).toHaveClass('custom-active') + }) + + it('should not apply activeClassName when not active', () => { + const { container } = render( + , + ) + expect(container.firstChild).not.toHaveClass('custom-active') + }) +}) + +describe('OptionCard', () => { + const defaultProps = { + icon: icon, + title: Card Title as React.ReactNode, + description: 'Card description', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render header content', () => { + render() + expect(screen.getByText('Card Title')).toBeInTheDocument() + expect(screen.getByText('Card description')).toBeInTheDocument() + }) + + it('should call onSwitched when clicked while not active and not disabled', () => { + const onSwitched = vi.fn() + const { container } = render( + , + ) + fireEvent.click(container.firstChild!) + expect(onSwitched).toHaveBeenCalledOnce() + }) + + it('should not call onSwitched when already active', () => { + const onSwitched = vi.fn() + const { container } = render( + , + ) + fireEvent.click(container.firstChild!) + expect(onSwitched).not.toHaveBeenCalled() + }) + + it('should not call onSwitched when disabled', () => { + const onSwitched = vi.fn() + const { container } = render( + , + ) + fireEvent.click(container.firstChild!) + expect(onSwitched).not.toHaveBeenCalled() + }) + + it('should show children and actions when active', () => { + render( + Action}> +
Body Content
+
, + ) + expect(screen.getByText('Body Content')).toBeInTheDocument() + expect(screen.getByText('Action')).toBeInTheDocument() + }) + + it('should not show children when not active', () => { + render( + +
Body Content
+
, + ) + expect(screen.queryByText('Body Content')).not.toBeInTheDocument() + }) + + it('should apply selected border style when active and not noHighlight', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('border-components-option-card-option-selected-border') + }) + + it('should not apply selected border when noHighlight is true', () => { + const { container } = render() + expect(container.firstChild).not.toHaveClass('border-components-option-card-option-selected-border') + }) + + it('should apply disabled opacity and pointer-events styles', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('pointer-events-none') + expect(container.firstChild).toHaveClass('opacity-50') + }) + + it('should forward custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should forward custom style', () => { + const { container } = render( + , + ) + expect((container.firstChild as HTMLElement).style.maxWidth).toBe('300px') + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx new file mode 100644 index 0000000000..7f33b04f48 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx @@ -0,0 +1,150 @@ +import type { ParentChildConfig } from '../../hooks' +import type { PreProcessingRule } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode } from '@/models/datasets' +import { ParentChildOptions } from '../parent-child-options' + +vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({ + default: ({ onSummaryIndexSettingChange }: { onSummaryIndexSettingChange?: (val: Record) => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/config', () => ({ + IS_CE_EDITION: true, +})) + +const ns = 'datasetCreation' + +const createRules = (): PreProcessingRule[] => [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, +] + +const createParentChildConfig = (overrides?: Partial): ParentChildConfig => ({ + chunkForContext: 'paragraph', + parent: { delimiter: '\\n\\n', maxLength: 2000 }, + child: { delimiter: '\\n', maxLength: 500 }, + ...overrides, +}) + +const defaultProps = { + parentChildConfig: createParentChildConfig(), + rules: createRules(), + currentDocForm: ChunkingMode.parentChild, + isActive: true, + isInUpload: false, + isNotUploadInEmptyDataset: false, + onDocFormChange: vi.fn(), + onChunkForContextChange: vi.fn(), + onParentDelimiterChange: vi.fn(), + onParentMaxLengthChange: vi.fn(), + onChildDelimiterChange: vi.fn(), + onChildMaxLengthChange: vi.fn(), + onRuleToggle: vi.fn(), + onPreview: vi.fn(), + onReset: vi.fn(), +} + +describe('ParentChildOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render parent-child title', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.parentChild`)).toBeInTheDocument() + }) + + it('should render parent chunk context section when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.parentChunkForContext`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.paragraph`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.fullDoc`)).toBeInTheDocument() + }) + + it('should render child chunk retrieval section when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.childChunkForRetrieval`)).toBeInTheDocument() + }) + + it('should render rules section when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.removeExtraSpaces`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)).toBeInTheDocument() + }) + + it('should render preview and reset buttons when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.previewChunk`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.reset`)).toBeInTheDocument() + }) + + it('should not render body when not active', () => { + render() + expect(screen.queryByText(`${ns}.stepTwo.parentChunkForContext`)).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onPreview when preview button clicked', () => { + const onPreview = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.previewChunk`)) + expect(onPreview).toHaveBeenCalledOnce() + }) + + it('should call onReset when reset button clicked', () => { + const onReset = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.reset`)) + expect(onReset).toHaveBeenCalledOnce() + }) + + it('should call onRuleToggle when rule clicked', () => { + const onRuleToggle = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)) + expect(onRuleToggle).toHaveBeenCalledWith('remove_urls_emails') + }) + + it('should call onDocFormChange with parentChild when card switched', () => { + const onDocFormChange = vi.fn() + render() + const titleEl = screen.getByText(`${ns}.stepTwo.parentChild`) + fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!) + expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.parentChild) + }) + + it('should call onChunkForContextChange when full-doc chosen', () => { + const onChunkForContextChange = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.fullDoc`)) + expect(onChunkForContextChange).toHaveBeenCalledWith('full-doc') + }) + + it('should call onChunkForContextChange when paragraph chosen', () => { + const onChunkForContextChange = vi.fn() + const config = createParentChildConfig({ chunkForContext: 'full-doc' }) + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.paragraph`)) + expect(onChunkForContextChange).toHaveBeenCalledWith('paragraph') + }) + }) + + describe('Summary Index Setting', () => { + it('should render SummaryIndexSetting when showSummaryIndexSetting is true', () => { + render() + expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument() + }) + + it('should not render SummaryIndexSetting when showSummaryIndexSetting is false', () => { + render() + expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx new file mode 100644 index 0000000000..5e61b53ad7 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx @@ -0,0 +1,166 @@ +import type { ParentChildConfig } from '../../hooks' +import type { FileIndexingEstimateResponse } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode, DataSourceType } from '@/models/datasets' +import { PreviewPanel } from '../preview-panel' + +vi.mock('@/app/components/base/float-right-container', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/base/badge', () => ({ + default: ({ text }: { text: string }) => {text}, +})) + +vi.mock('@/app/components/base/skeleton', () => ({ + SkeletonContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + SkeletonPoint: () => , + SkeletonRectangle: () => , + SkeletonRow: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('../../../../chunk', () => ({ + ChunkContainer: ({ children, label }: { children: React.ReactNode, label: string }) => ( +
+ {label} + : + {' '} + {children} +
+ ), + QAPreview: ({ qa }: { qa: { question: string } }) =>
{qa.question}
, +})) + +vi.mock('../../../../common/document-picker/preview-document-picker', () => ({ + default: () =>
, +})) + +vi.mock('../../../../documents/detail/completed/common/summary-label', () => ({ + default: ({ summary }: { summary: string }) => {summary}, +})) + +vi.mock('../../../../formatted-text/flavours/preview-slice', () => ({ + PreviewSlice: ({ label, text }: { label: string, text: string }) => ( + + {label} + : + {' '} + {text} + + ), +})) + +vi.mock('../../../../formatted-text/formatted', () => ({ + FormattedText: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) + +vi.mock('../../../../preview/container', () => ({ + default: ({ children, header }: { children: React.ReactNode, header: React.ReactNode }) => ( +
+ {header} + {children} +
+ ), +})) + +vi.mock('../../../../preview/header', () => ({ + PreviewHeader: ({ children, title }: { children: React.ReactNode, title: string }) => ( +
+ {title} + {children} +
+ ), +})) + +vi.mock('@/config', () => ({ + FULL_DOC_PREVIEW_LENGTH: 3, +})) + +describe('PreviewPanel', () => { + const defaultProps = { + isMobile: false, + dataSourceType: DataSourceType.FILE, + currentDocForm: ChunkingMode.text, + parentChildConfig: { chunkForContext: 'paragraph' } as ParentChildConfig, + pickerFiles: [{ id: '1', name: 'file.pdf', extension: 'pdf' }], + pickerValue: { id: '1', name: 'file.pdf', extension: 'pdf' }, + isIdle: false, + isPending: false, + onPickerChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render preview header with title', () => { + render() + expect(screen.getByTestId('preview-header')).toHaveTextContent('datasetCreation.stepTwo.preview') + }) + + it('should render document picker', () => { + render() + expect(screen.getByTestId('doc-picker')).toBeInTheDocument() + }) + + it('should show idle state when isIdle is true', () => { + render() + expect(screen.getByText('datasetCreation.stepTwo.previewChunkTip')).toBeInTheDocument() + }) + + it('should show loading skeletons when isPending', () => { + render() + expect(screen.getAllByTestId('skeleton')).toHaveLength(10) + }) + + it('should render text preview chunks', () => { + const estimate: Partial = { + total_segments: 2, + preview: [ + { content: 'chunk 1 text', child_chunks: [], summary: '' }, + { content: 'chunk 2 text', child_chunks: [], summary: 'summary text' }, + ], + } + render() + expect(screen.getAllByTestId('chunk-container')).toHaveLength(2) + }) + + it('should render QA preview', () => { + const estimate: Partial = { + qa_preview: [ + { question: 'Q1', answer: 'A1' }, + ], + } + render( + , + ) + expect(screen.getByTestId('qa-preview')).toHaveTextContent('Q1') + }) + + it('should render parent-child preview', () => { + const estimate: Partial = { + preview: [ + { content: 'parent chunk', child_chunks: ['child1', 'child2'], summary: '' }, + ], + } + render( + , + ) + expect(screen.getAllByTestId('preview-slice')).toHaveLength(2) + }) + + it('should show badge with chunk count for non-QA mode', () => { + const estimate: Partial = { total_segments: 5, preview: [] } + render() + expect(screen.getByTestId('badge')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/step-two-footer.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/step-two-footer.spec.tsx new file mode 100644 index 0000000000..ace92d3f64 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/step-two-footer.spec.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { StepTwoFooter } from '../step-two-footer' + +describe('StepTwoFooter', () => { + const defaultProps = { + isCreating: false, + onPrevious: vi.fn(), + onCreate: vi.fn(), + onCancel: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render previous and next buttons when not isSetting', () => { + render() + expect(screen.getByText('datasetCreation.stepTwo.previousStep')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepTwo.nextStep')).toBeInTheDocument() + }) + + it('should render save and cancel buttons when isSetting', () => { + render() + expect(screen.getByText('datasetCreation.stepTwo.save')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepTwo.cancel')).toBeInTheDocument() + }) + + it('should call onPrevious on previous button click', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepTwo.previousStep')) + expect(defaultProps.onPrevious).toHaveBeenCalledOnce() + }) + + it('should call onCreate on next button click', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepTwo.nextStep')) + expect(defaultProps.onCreate).toHaveBeenCalledOnce() + }) + + it('should call onCancel on cancel button click in settings mode', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepTwo.cancel')) + expect(defaultProps.onCancel).toHaveBeenCalledOnce() + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts new file mode 100644 index 0000000000..0f0b167822 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import escape from '../escape' + +describe('escape', () => { + // Basic special character escaping + it('should escape null character', () => { + expect(escape('\0')).toBe('\\0') + }) + + it('should escape backspace', () => { + expect(escape('\b')).toBe('\\b') + }) + + it('should escape form feed', () => { + expect(escape('\f')).toBe('\\f') + }) + + it('should escape newline', () => { + expect(escape('\n')).toBe('\\n') + }) + + it('should escape carriage return', () => { + expect(escape('\r')).toBe('\\r') + }) + + it('should escape tab', () => { + expect(escape('\t')).toBe('\\t') + }) + + it('should escape vertical tab', () => { + expect(escape('\v')).toBe('\\v') + }) + + it('should escape single quote', () => { + expect(escape('\'')).toBe('\\\'') + }) + + // Multiple special characters in one string + it('should escape multiple special characters', () => { + expect(escape('line1\nline2\ttab')).toBe('line1\\nline2\\ttab') + }) + + it('should escape mixed special characters', () => { + expect(escape('\n\r\t')).toBe('\\n\\r\\t') + }) + + it('should return empty string for null input', () => { + expect(escape(null as unknown as string)).toBe('') + }) + + it('should return empty string for undefined input', () => { + expect(escape(undefined as unknown as string)).toBe('') + }) + + it('should return empty string for empty string input', () => { + expect(escape('')).toBe('') + }) + + it('should return empty string for non-string input', () => { + expect(escape(123 as unknown as string)).toBe('') + }) + + // Pass-through for normal strings + it('should leave normal text unchanged', () => { + expect(escape('hello world')).toBe('hello world') + }) + + it('should leave special regex characters unchanged', () => { + expect(escape('a.b*c+d')).toBe('a.b*c+d') + }) + + it('should handle strings with no special characters', () => { + expect(escape('abc123')).toBe('abc123') + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts new file mode 100644 index 0000000000..b0261e6250 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import unescape from '../unescape' + +describe('unescape', () => { + // Basic escape sequences + it('should unescape \\n to newline', () => { + expect(unescape('\\n')).toBe('\n') + }) + + it('should unescape \\t to tab', () => { + expect(unescape('\\t')).toBe('\t') + }) + + it('should unescape \\r to carriage return', () => { + expect(unescape('\\r')).toBe('\r') + }) + + it('should unescape \\b to backspace', () => { + expect(unescape('\\b')).toBe('\b') + }) + + it('should unescape \\f to form feed', () => { + expect(unescape('\\f')).toBe('\f') + }) + + it('should unescape \\v to vertical tab', () => { + expect(unescape('\\v')).toBe('\v') + }) + + it('should unescape \\0 to null character', () => { + expect(unescape('\\0')).toBe('\0') + }) + + it('should unescape \\\\ to backslash', () => { + expect(unescape('\\\\')).toBe('\\') + }) + + it('should unescape \\\' to single quote', () => { + expect(unescape('\\\'')).toBe('\'') + }) + + it('should unescape \\" to double quote', () => { + expect(unescape('\\"')).toBe('"') + }) + + // Hex escape sequences (\\xNN) + it('should unescape 2-digit hex sequences', () => { + expect(unescape('\\x41')).toBe('A') + expect(unescape('\\x61')).toBe('a') + }) + + // Unicode escape sequences (\\uNNNN) + it('should unescape 4-digit unicode sequences', () => { + expect(unescape('\\u0041')).toBe('A') + expect(unescape('\\u4e2d')).toBe('äž­') + }) + + // Variable-length unicode (\\u{NNNN}) + it('should unescape variable-length unicode sequences', () => { + expect(unescape('\\u{41}')).toBe('A') + expect(unescape('\\u{1F600}')).toBe('😀') + }) + + // Octal escape sequences + it('should unescape octal sequences', () => { + expect(unescape('\\101')).toBe('A') // 0o101 = 65 = 'A' + expect(unescape('\\12')).toBe('\n') // 0o12 = 10 = '\n' + }) + + // Python-style 8-digit unicode (\\UNNNNNNNN) + it('should unescape Python-style 8-digit unicode', () => { + expect(unescape('\\U0001F3B5')).toBe('đŸŽ”') + }) + + // Multiple escape sequences + it('should unescape multiple sequences in one string', () => { + expect(unescape('line1\\nline2\\ttab')).toBe('line1\nline2\ttab') + }) + + // Mixed content + it('should leave non-escape content unchanged', () => { + expect(unescape('hello world')).toBe('hello world') + }) + + it('should handle mixed escaped and non-escaped content', () => { + expect(unescape('before\\nafter')).toBe('before\nafter') + }) + + it('should handle empty string', () => { + expect(unescape('')).toBe('') + }) + + it('should handle string with no escape sequences', () => { + expect(unescape('abc123')).toBe('abc123') + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts new file mode 100644 index 0000000000..74c37c876b --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts @@ -0,0 +1,186 @@ +import type { CustomFile, FullDocumentDetail, ProcessRule } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode, DataSourceType } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +// Hoisted mocks +const mocks = vi.hoisted(() => ({ + toastNotify: vi.fn(), + mutateAsync: vi.fn(), + isReRankModelSelected: vi.fn(() => true), + trackEvent: vi.fn(), + invalidDatasetList: vi.fn(), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: mocks.toastNotify }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: mocks.trackEvent, +})) + +vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ + isReRankModelSelected: mocks.isReRankModelSelected, +})) + +vi.mock('@/service/knowledge/use-create-dataset', () => ({ + useCreateFirstDocument: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }), + useCreateDocument: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }), + getNotionInfo: vi.fn(() => []), + getWebsiteInfo: vi.fn(() => ({})), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => mocks.invalidDatasetList, +})) + +const { useDocumentCreation } = await import('../use-document-creation') +const { IndexingType } = await import('../use-indexing-config') + +describe('useDocumentCreation', () => { + const defaultOptions = { + dataSourceType: DataSourceType.FILE, + files: [{ id: 'f-1', name: 'test.txt' }] as CustomFile[], + notionPages: [], + notionCredentialId: '', + websitePages: [], + } + + const defaultValidationParams = { + segmentationType: 'general', + maxChunkLength: 1024, + limitMaxChunkLength: 4000, + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-3-small' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + } as RetrievalConfig, + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.isReRankModelSelected.mockReturnValue(true) + }) + + describe('validateParams', () => { + it('should return true for valid params', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + expect(result.current.validateParams(defaultValidationParams)).toBe(true) + }) + + it('should return false when overlap > maxChunkLength', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const invalid = { ...defaultValidationParams, overlap: 2000, maxChunkLength: 1000 } + expect(result.current.validateParams(invalid)).toBe(false) + expect(mocks.toastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should return false when maxChunkLength > limitMaxChunkLength', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const invalid = { ...defaultValidationParams, maxChunkLength: 5000, limitMaxChunkLength: 4000 } + expect(result.current.validateParams(invalid)).toBe(false) + }) + + it('should return false when qualified but no embedding model', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const invalid = { + ...defaultValidationParams, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: '', model: '' }, + } + expect(result.current.validateParams(invalid)).toBe(false) + }) + + it('should return false when rerank model not selected', () => { + mocks.isReRankModelSelected.mockReturnValue(false) + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + expect(result.current.validateParams(defaultValidationParams)).toBe(false) + }) + + it('should skip embedding/rerank checks when isSetting is true', () => { + mocks.isReRankModelSelected.mockReturnValue(false) + const { result } = renderHook(() => + useDocumentCreation({ ...defaultOptions, isSetting: true }), + ) + const params = { + ...defaultValidationParams, + embeddingModel: { provider: '', model: '' }, + } + expect(result.current.validateParams(params)).toBe(true) + }) + }) + + describe('buildCreationParams', () => { + it('should build params for FILE data source', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const processRule = { mode: 'custom', rules: {} } as unknown as ProcessRule + const retrievalConfig = defaultValidationParams.retrievalConfig + const embeddingModel = { provider: 'openai', model: 'text-embedding-3-small' } + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + processRule, + retrievalConfig, + embeddingModel, + 'high_quality', + ) + + expect(params).not.toBeNull() + expect(params!.data_source!.type).toBe(DataSourceType.FILE) + expect(params!.data_source!.info_list.file_info_list?.file_ids).toContain('f-1') + expect(params!.embedding_model).toBe('text-embedding-3-small') + expect(params!.embedding_model_provider).toBe('openai') + }) + + it('should build params for isSetting mode', () => { + const detail = { id: 'doc-1' } as FullDocumentDetail + const { result } = renderHook(() => + useDocumentCreation({ ...defaultOptions, isSetting: true, documentDetail: detail }), + ) + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: 'custom', rules: {} } as unknown as ProcessRule, + defaultValidationParams.retrievalConfig, + { provider: 'openai', model: 'text-embedding-3-small' }, + 'high_quality', + ) + + expect(params!.original_document_id).toBe('doc-1') + expect(params!.data_source).toBeUndefined() + }) + }) + + describe('validatePreviewParams', () => { + it('should return true when maxChunkLength is within limit', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + expect(result.current.validatePreviewParams(1024)).toBe(true) + }) + + it('should return false when maxChunkLength exceeds limit', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + expect(result.current.validatePreviewParams(999999)).toBe(false) + expect(mocks.toastNotify).toHaveBeenCalled() + }) + }) + + describe('isCreating', () => { + it('should reflect mutation pending state', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + expect(result.current.isCreating).toBe(false) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts new file mode 100644 index 0000000000..1ac13aee76 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts @@ -0,0 +1,161 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RETRIEVE_METHOD } from '@/types/app' + +// Hoisted mock state +const mocks = vi.hoisted(() => ({ + rerankModelList: [] as Array<{ provider: { provider: string }, model: string }>, + rerankDefaultModel: null as { provider: { provider: string }, model: string } | null, + isRerankDefaultModelValid: null as { provider: { provider: string }, model: string } | null, + embeddingModelList: [] as Array<{ provider: { provider: string }, model: string }>, + defaultEmbeddingModel: null as { provider: { provider: string }, model: string } | null, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ + modelList: mocks.rerankModelList, + defaultModel: mocks.rerankDefaultModel, + currentModel: mocks.isRerankDefaultModelValid, + }), + useModelList: () => ({ data: mocks.embeddingModelList }), + useDefaultModel: () => ({ data: mocks.defaultEmbeddingModel }), +})) + +vi.mock('@/app/components/datasets/settings/utils', () => ({ + checkShowMultiModalTip: vi.fn(() => false), +})) + +const { IndexingType, useIndexingConfig } = await import('../use-indexing-config') + +describe('useIndexingConfig', () => { + const defaultOptions = { + isAPIKeySet: true, + hasSetIndexType: false, + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.rerankModelList = [] + mocks.rerankDefaultModel = null + mocks.isRerankDefaultModelValid = null + mocks.embeddingModelList = [] + mocks.defaultEmbeddingModel = null + }) + + describe('initial state', () => { + it('should default to QUALIFIED when API key is set', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + expect(result.current.indexType).toBe(IndexingType.QUALIFIED) + }) + + it('should default to ECONOMICAL when API key is not set', () => { + const { result } = renderHook(() => + useIndexingConfig({ ...defaultOptions, isAPIKeySet: false }), + ) + expect(result.current.indexType).toBe(IndexingType.ECONOMICAL) + }) + + it('should use initial index type when provided', () => { + const { result } = renderHook(() => + useIndexingConfig({ + ...defaultOptions, + initialIndexType: IndexingType.ECONOMICAL, + }), + ) + expect(result.current.indexType).toBe(IndexingType.ECONOMICAL) + }) + + it('should use initial embedding model when provided', () => { + const { result } = renderHook(() => + useIndexingConfig({ + ...defaultOptions, + initialEmbeddingModel: { provider: 'openai', model: 'text-embedding-3-small' }, + }), + ) + expect(result.current.embeddingModel).toEqual({ + provider: 'openai', + model: 'text-embedding-3-small', + }) + }) + + it('should use initial retrieval config when provided', () => { + const config = { + search_method: RETRIEVE_METHOD.fullText, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 5, + score_threshold_enabled: true, + score_threshold: 0.8, + } + const { result } = renderHook(() => + useIndexingConfig({ ...defaultOptions, initialRetrievalConfig: config }), + ) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.fullText) + expect(result.current.retrievalConfig.top_k).toBe(5) + }) + }) + + describe('setters', () => { + it('should update index type', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + + act(() => { + result.current.setIndexType(IndexingType.ECONOMICAL) + }) + expect(result.current.indexType).toBe(IndexingType.ECONOMICAL) + }) + + it('should update embedding model', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + + act(() => { + result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' }) + }) + expect(result.current.embeddingModel).toEqual({ provider: 'cohere', model: 'embed-v3' }) + }) + + it('should update retrieval config', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + const newConfig = { + ...result.current.retrievalConfig, + top_k: 10, + } + + act(() => { + result.current.setRetrievalConfig(newConfig) + }) + expect(result.current.retrievalConfig.top_k).toBe(10) + }) + }) + + describe('getIndexingTechnique', () => { + it('should return initialIndexType when provided', () => { + const { result } = renderHook(() => + useIndexingConfig({ + ...defaultOptions, + initialIndexType: IndexingType.ECONOMICAL, + }), + ) + expect(result.current.getIndexingTechnique()).toBe(IndexingType.ECONOMICAL) + }) + + it('should return current indexType when no initialIndexType', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + expect(result.current.getIndexingTechnique()).toBe(IndexingType.QUALIFIED) + }) + }) + + describe('computed properties', () => { + it('should expose hasSetIndexType from options', () => { + const { result } = renderHook(() => + useIndexingConfig({ ...defaultOptions, hasSetIndexType: true }), + ) + expect(result.current.hasSetIndexType).toBe(true) + }) + + it('should expose showMultiModalTip as boolean', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + expect(typeof result.current.showMultiModalTip).toBe('boolean') + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts new file mode 100644 index 0000000000..59676e68a8 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts @@ -0,0 +1,127 @@ +import type { IndexingType } from '../use-indexing-config' +import type { NotionPage } from '@/models/common' +import type { ChunkingMode, CrawlResultItem, CustomFile, ProcessRule } from '@/models/datasets' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' + +// Hoisted mocks +const mocks = vi.hoisted(() => ({ + fileMutate: vi.fn(), + fileReset: vi.fn(), + notionMutate: vi.fn(), + notionReset: vi.fn(), + webMutate: vi.fn(), + webReset: vi.fn(), +})) + +vi.mock('@/service/knowledge/use-create-dataset', () => ({ + useFetchFileIndexingEstimateForFile: () => ({ + mutate: mocks.fileMutate, + reset: mocks.fileReset, + data: { tokens: 100, total_segments: 5 }, + isIdle: true, + isPending: false, + }), + useFetchFileIndexingEstimateForNotion: () => ({ + mutate: mocks.notionMutate, + reset: mocks.notionReset, + data: null, + isIdle: true, + isPending: false, + }), + useFetchFileIndexingEstimateForWeb: () => ({ + mutate: mocks.webMutate, + reset: mocks.webReset, + data: null, + isIdle: true, + isPending: false, + }), +})) + +const { useIndexingEstimate } = await import('../use-indexing-estimate') + +describe('useIndexingEstimate', () => { + const defaultOptions = { + dataSourceType: DataSourceType.FILE, + currentDocForm: 'text_model' as ChunkingMode, + docLanguage: 'English', + files: [{ id: 'f-1', name: 'test.txt' }] as unknown as CustomFile[], + previewNotionPage: {} as unknown as NotionPage, + notionCredentialId: '', + previewWebsitePage: {} as unknown as CrawlResultItem, + indexingTechnique: 'high_quality' as unknown as IndexingType, + processRule: { mode: 'custom', rules: {} } as unknown as ProcessRule, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('currentMutation selection', () => { + it('should select file mutation for FILE type', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + expect(result.current.estimate).toEqual({ tokens: 100, total_segments: 5 }) + }) + + it('should select notion mutation for NOTION type', () => { + const { result } = renderHook(() => useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + })) + expect(result.current.estimate).toBeNull() + }) + + it('should select web mutation for WEB type', () => { + const { result } = renderHook(() => useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + })) + expect(result.current.estimate).toBeNull() + }) + }) + + describe('fetchEstimate', () => { + it('should call file mutate for FILE type', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + result.current.fetchEstimate() + expect(mocks.fileMutate).toHaveBeenCalledOnce() + }) + + it('should call notion mutate for NOTION type', () => { + const { result } = renderHook(() => useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + })) + result.current.fetchEstimate() + expect(mocks.notionMutate).toHaveBeenCalledOnce() + }) + + it('should call web mutate for WEB type', () => { + const { result } = renderHook(() => useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + })) + result.current.fetchEstimate() + expect(mocks.webMutate).toHaveBeenCalledOnce() + }) + }) + + describe('state properties', () => { + it('should expose isIdle', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + expect(result.current.isIdle).toBe(true) + }) + + it('should expose isPending', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + expect(result.current.isPending).toBe(false) + }) + + it('should expose reset function', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + result.current.reset() + expect(mocks.fileReset).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts new file mode 100644 index 0000000000..b13dcb5327 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts @@ -0,0 +1,198 @@ +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem, CustomFile } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' +import { usePreviewState } from '../use-preview-state' + +// Factory functions +const createFile = (id: string, name: string): CustomFile => ({ + id, + name, + size: 1024, + type: 'text/plain', + extension: 'txt', + created_by: 'user', + created_at: Date.now(), +} as unknown as CustomFile) + +const createNotionPage = (pageId: string, pageName: string): NotionPage => ({ + page_id: pageId, + page_name: pageName, + page_icon: null, + parent_id: '', + type: 'page', + is_bound: true, +} as unknown as NotionPage) + +const createWebsitePage = (url: string, title: string): CrawlResultItem => ({ + source_url: url, + title, + markdown: '', + description: '', +} as unknown as CrawlResultItem) + +describe('usePreviewState', () => { + const files = [createFile('f-1', 'file1.txt'), createFile('f-2', 'file2.txt')] + const notionPages = [createNotionPage('np-1', 'Page 1'), createNotionPage('np-2', 'Page 2')] + const websitePages = [createWebsitePage('https://a.com', 'Site A'), createWebsitePage('https://b.com', 'Site B')] + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('initial state for FILE', () => { + it('should set first file as preview', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + })) + expect(result.current.previewFile).toBe(files[0]) + }) + }) + + describe('initial state for NOTION', () => { + it('should set first notion page as preview', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + })) + expect(result.current.previewNotionPage).toBe(notionPages[0]) + }) + }) + + describe('initial state for WEB', () => { + it('should set first website page as preview', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages, + })) + expect(result.current.previewWebsitePage).toBe(websitePages[0]) + }) + }) + + describe('getPreviewPickerItems', () => { + it('should return files for FILE type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + })) + const items = result.current.getPreviewPickerItems() + expect(items).toHaveLength(2) + }) + + it('should return mapped notion pages for NOTION type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + })) + const items = result.current.getPreviewPickerItems() + expect(items).toHaveLength(2) + expect(items[0]).toEqual({ id: 'np-1', name: 'Page 1', extension: 'md' }) + }) + + it('should return mapped website pages for WEB type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages, + })) + const items = result.current.getPreviewPickerItems() + expect(items).toHaveLength(2) + expect(items[0]).toEqual({ id: 'https://a.com', name: 'Site A', extension: 'md' }) + }) + }) + + describe('getPreviewPickerValue', () => { + it('should return current preview file for FILE type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + })) + const value = result.current.getPreviewPickerValue() + expect(value).toBe(files[0]) + }) + + it('should return mapped notion page value for NOTION type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + })) + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ id: 'np-1', name: 'Page 1', extension: 'md' }) + }) + }) + + describe('handlePreviewChange', () => { + it('should change preview file for FILE type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + })) + + act(() => { + result.current.handlePreviewChange({ id: 'f-2', name: 'file2.txt' }) + }) + expect(result.current.previewFile).toEqual({ id: 'f-2', name: 'file2.txt' }) + }) + + it('should change preview notion page for NOTION type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + })) + + act(() => { + result.current.handlePreviewChange({ id: 'np-2', name: 'Page 2' }) + }) + expect(result.current.previewNotionPage).toBe(notionPages[1]) + }) + + it('should change preview website page for WEB type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages, + })) + + act(() => { + result.current.handlePreviewChange({ id: 'https://b.com', name: 'Site B' }) + }) + expect(result.current.previewWebsitePage).toBe(websitePages[1]) + }) + + it('should not change if selected page not found (NOTION)', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + })) + + act(() => { + result.current.handlePreviewChange({ id: 'non-existent', name: 'x' }) + }) + expect(result.current.previewNotionPage).toBe(notionPages[0]) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-segmentation-state.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-segmentation-state.spec.ts new file mode 100644 index 0000000000..bdf0de31e4 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-segmentation-state.spec.ts @@ -0,0 +1,372 @@ +import type { PreProcessingRule, Rules } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode, ProcessMode } from '@/models/datasets' +import { + DEFAULT_MAXIMUM_CHUNK_LENGTH, + DEFAULT_OVERLAP, + DEFAULT_SEGMENT_IDENTIFIER, + defaultParentChildConfig, + useSegmentationState, +} from '../use-segmentation-state' + +describe('useSegmentationState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // --- Default state --- + describe('default state', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useSegmentationState()) + + expect(result.current.segmentationType).toBe(ProcessMode.general) + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + expect(result.current.overlap).toBe(DEFAULT_OVERLAP) + expect(result.current.rules).toEqual([]) + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + + it('should accept initial segmentation type', () => { + const { result } = renderHook(() => + useSegmentationState({ initialSegmentationType: ProcessMode.parentChild }), + ) + expect(result.current.segmentationType).toBe(ProcessMode.parentChild) + }) + + it('should accept initial summary index setting', () => { + const setting = { enable: true } + const { result } = renderHook(() => + useSegmentationState({ initialSummaryIndexSetting: setting }), + ) + expect(result.current.summaryIndexSetting).toEqual(setting) + }) + }) + + // --- Setters --- + describe('setters', () => { + it('should update segmentation type', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentationType(ProcessMode.parentChild) + }) + expect(result.current.segmentationType).toBe(ProcessMode.parentChild) + }) + + it('should update max chunk length', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setMaxChunkLength(2048) + }) + expect(result.current.maxChunkLength).toBe(2048) + }) + + it('should update overlap', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setOverlap(100) + }) + expect(result.current.overlap).toBe(100) + }) + + it('should update rules', () => { + const newRules: PreProcessingRule[] = [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ] + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setRules(newRules) + }) + expect(result.current.rules).toEqual(newRules) + }) + }) + + // --- Segment identifier with escaping --- + describe('setSegmentIdentifier', () => { + it('should escape the value when setting', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('\n\n') + }) + expect(result.current.segmentIdentifier).toBe('\\n\\n') + }) + + it('should reset to default when empty and canEmpty is false', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('') + }) + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + }) + + it('should allow empty value when canEmpty is true', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('', true) + }) + expect(result.current.segmentIdentifier).toBe('') + }) + }) + + // --- Toggle rule --- + describe('toggleRule', () => { + it('should toggle a rule enabled state', () => { + const { result } = renderHook(() => useSegmentationState()) + const rules: PreProcessingRule[] = [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ] + + act(() => { + result.current.setRules(rules) + }) + act(() => { + result.current.toggleRule('remove_extra_spaces') + }) + + expect(result.current.rules[0].enabled).toBe(false) + expect(result.current.rules[1].enabled).toBe(false) + }) + + it('should toggle second rule without affecting first', () => { + const { result } = renderHook(() => useSegmentationState()) + const rules: PreProcessingRule[] = [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ] + + act(() => { + result.current.setRules(rules) + }) + act(() => { + result.current.toggleRule('remove_urls_emails') + }) + + expect(result.current.rules[0].enabled).toBe(true) + expect(result.current.rules[1].enabled).toBe(true) + }) + }) + + // --- Parent-child config --- + describe('parent-child config', () => { + it('should update parent delimiter with escaping', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('delimiter', '\n') + }) + expect(result.current.parentChildConfig.parent.delimiter).toBe('\\n') + }) + + it('should update parent maxLength', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('maxLength', 2048) + }) + expect(result.current.parentChildConfig.parent.maxLength).toBe(2048) + }) + + it('should update child delimiter with escaping', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateChildConfig('delimiter', '\t') + }) + expect(result.current.parentChildConfig.child.delimiter).toBe('\\t') + }) + + it('should update child maxLength', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateChildConfig('maxLength', 256) + }) + expect(result.current.parentChildConfig.child.maxLength).toBe(256) + }) + + it('should set empty delimiter when value is empty', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('delimiter', '') + }) + expect(result.current.parentChildConfig.parent.delimiter).toBe('') + }) + + it('should set chunk for context mode', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setChunkForContext('full-doc') + }) + expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc') + }) + }) + + // --- Reset to defaults --- + describe('resetToDefaults', () => { + it('should reset to default config when defaults are set', () => { + const { result } = renderHook(() => useSegmentationState()) + const defaultRules: Rules = { + pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }], + segmentation: { + separator: '---', + max_tokens: 500, + chunk_overlap: 25, + }, + parent_mode: 'paragraph', + subchunk_segmentation: { + separator: '\n', + max_tokens: 200, + }, + } + + act(() => { + result.current.setDefaultConfig(defaultRules) + }) + // Change values + act(() => { + result.current.setMaxChunkLength(2048) + result.current.setOverlap(200) + }) + act(() => { + result.current.resetToDefaults() + }) + + expect(result.current.maxChunkLength).toBe(500) + expect(result.current.overlap).toBe(25) + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + + it('should reset parent-child config even without default config', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('maxLength', 9999) + }) + act(() => { + result.current.resetToDefaults() + }) + + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + }) + + // --- applyConfigFromRules --- + describe('applyConfigFromRules', () => { + it('should apply general config from rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const rulesConfig: Rules = { + pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }], + segmentation: { + separator: '|||', + max_tokens: 800, + chunk_overlap: 30, + }, + parent_mode: 'paragraph', + subchunk_segmentation: { + separator: '\n', + max_tokens: 200, + }, + } + + act(() => { + result.current.applyConfigFromRules(rulesConfig, false) + }) + + expect(result.current.maxChunkLength).toBe(800) + expect(result.current.overlap).toBe(30) + expect(result.current.rules).toEqual(rulesConfig.pre_processing_rules) + }) + + it('should apply hierarchical config from rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const rulesConfig: Rules = { + pre_processing_rules: [], + segmentation: { + separator: '\n\n', + max_tokens: 1024, + chunk_overlap: 50, + }, + parent_mode: 'full-doc', + subchunk_segmentation: { + separator: '\n', + max_tokens: 256, + }, + } + + act(() => { + result.current.applyConfigFromRules(rulesConfig, true) + }) + + expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc') + expect(result.current.parentChildConfig.child.maxLength).toBe(256) + }) + }) + + // --- getProcessRule --- + describe('getProcessRule', () => { + it('should build general process rule', () => { + const { result } = renderHook(() => useSegmentationState()) + + const rule = result.current.getProcessRule(ChunkingMode.text) + expect(rule.mode).toBe(ProcessMode.general) + expect(rule.rules!.segmentation.max_tokens).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + expect(rule.rules!.segmentation.chunk_overlap).toBe(DEFAULT_OVERLAP) + }) + + it('should build parent-child process rule', () => { + const { result } = renderHook(() => useSegmentationState()) + + const rule = result.current.getProcessRule(ChunkingMode.parentChild) + expect(rule.mode).toBe('hierarchical') + expect(rule.rules!.parent_mode).toBe('paragraph') + expect(rule.rules!.subchunk_segmentation).toBeDefined() + }) + + it('should include summary index setting in process rule', () => { + const setting = { enable: true } + const { result } = renderHook(() => + useSegmentationState({ initialSummaryIndexSetting: setting }), + ) + + const rule = result.current.getProcessRule(ChunkingMode.text) + expect(rule.summary_index_setting).toEqual(setting) + }) + }) + + // --- Summary index setting --- + describe('handleSummaryIndexSettingChange', () => { + it('should update summary index setting', () => { + const { result } = renderHook(() => + useSegmentationState({ initialSummaryIndexSetting: { enable: false } }), + ) + + act(() => { + result.current.handleSummaryIndexSettingChange({ enable: true }) + }) + expect(result.current.summaryIndexSetting).toEqual({ enable: true }) + }) + + it('should merge with existing setting', () => { + const { result } = renderHook(() => + useSegmentationState({ initialSummaryIndexSetting: { enable: true } }), + ) + + act(() => { + result.current.handleSummaryIndexSettingChange({ enable: false }) + }) + expect(result.current.summaryIndexSetting?.enable).toBe(false) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/language-select/index.spec.tsx b/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/datasets/create/step-two/language-select/index.spec.tsx rename to web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx index a2f0d96d80..759bf69f4c 100644 --- a/web/app/components/datasets/create/step-two/language-select/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { ILanguageSelectProps } from './index' +import type { ILanguageSelectProps } from '../index' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { languages } from '@/i18n-config/language' -import LanguageSelect from './index' +import LanguageSelect from '../index' // Get supported languages for test assertions const supportedLanguages = languages.filter(lang => lang.supported) @@ -20,37 +20,27 @@ describe('LanguageSelect', () => { vi.clearAllMocks() }) - // ========================================== // Rendering Tests - Verify component renders correctly - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('English')).toBeInTheDocument() }) it('should render current language text', () => { - // Arrange const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' }) - // Act render() - // Assert expect(screen.getByText('Chinese Simplified')).toBeInTheDocument() }) it('should render dropdown arrow icon', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() // Assert - RiArrowDownSLine renders as SVG @@ -59,7 +49,6 @@ describe('LanguageSelect', () => { }) it('should render all supported languages in dropdown when opened', () => { - // Arrange const props = createDefaultProps() render() @@ -75,12 +64,10 @@ describe('LanguageSelect', () => { }) it('should render check icon for selected language', () => { - // Arrange const selectedLanguage = 'Japanese' const props = createDefaultProps({ currentLanguage: selectedLanguage }) render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -91,9 +78,7 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // Props Testing - Verify all prop variations work correctly - // ========================================== describe('Props', () => { describe('currentLanguage prop', () => { it('should display English when currentLanguage is English', () => { @@ -126,47 +111,36 @@ describe('LanguageSelect', () => { describe('disabled prop', () => { it('should have disabled button when disabled is true', () => { - // Arrange const props = createDefaultProps({ disabled: true }) - // Act render() - // Assert const button = screen.getByRole('button') expect(button).toBeDisabled() }) it('should have enabled button when disabled is false', () => { - // Arrange const props = createDefaultProps({ disabled: false }) - // Act render() - // Assert const button = screen.getByRole('button') expect(button).not.toBeDisabled() }) it('should have enabled button when disabled is undefined', () => { - // Arrange const props = createDefaultProps() delete (props as Partial).disabled - // Act render() - // Assert const button = screen.getByRole('button') expect(button).not.toBeDisabled() }) it('should apply disabled styling when disabled is true', () => { - // Arrange const props = createDefaultProps({ disabled: true }) - // Act const { container } = render() // Assert - Check for disabled class on text elements @@ -175,13 +149,10 @@ describe('LanguageSelect', () => { }) it('should apply cursor-not-allowed styling when disabled', () => { - // Arrange const props = createDefaultProps({ disabled: true }) - // Act const { container } = render() - // Assert const elementWithCursor = container.querySelector('.cursor-not-allowed') expect(elementWithCursor).toBeInTheDocument() }) @@ -205,16 +176,12 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // User Interactions - Test event handlers - // ========================================== describe('User Interactions', () => { it('should open dropdown when button is clicked', () => { - // Arrange const props = createDefaultProps() render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -223,24 +190,20 @@ describe('LanguageSelect', () => { }) it('should call onSelect when a language option is clicked', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) render() - // Act const button = screen.getByRole('button') fireEvent.click(button) const frenchOption = screen.getByText('French') fireEvent.click(frenchOption) - // Assert expect(mockOnSelect).toHaveBeenCalledTimes(1) expect(mockOnSelect).toHaveBeenCalledWith('French') }) it('should call onSelect with correct language when selecting different languages', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) render() @@ -259,11 +222,9 @@ describe('LanguageSelect', () => { }) it('should not open dropdown when disabled', () => { - // Arrange const props = createDefaultProps({ disabled: true }) render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -273,21 +234,17 @@ describe('LanguageSelect', () => { }) it('should not call onSelect when component is disabled', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect, disabled: true }) render() - // Act const button = screen.getByRole('button') fireEvent.click(button) - // Assert expect(mockOnSelect).not.toHaveBeenCalled() }) it('should handle rapid consecutive clicks', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) render() @@ -303,9 +260,7 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // Component Memoization - Test React.memo behavior - // ========================================== describe('Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - Check component has memo wrapper @@ -313,7 +268,6 @@ describe('LanguageSelect', () => { }) it('should not re-render when props remain the same', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) const renderSpy = vi.fn() @@ -325,7 +279,6 @@ describe('LanguageSelect', () => { } const MemoizedTracked = React.memo(TrackedLanguageSelect) - // Act const { rerender } = render() rerender() @@ -334,43 +287,33 @@ describe('LanguageSelect', () => { }) it('should re-render when currentLanguage changes', () => { - // Arrange const props = createDefaultProps({ currentLanguage: 'English' }) - // Act const { rerender } = render() expect(screen.getByText('English')).toBeInTheDocument() rerender() - // Assert expect(screen.getByText('French')).toBeInTheDocument() }) it('should re-render when disabled changes', () => { - // Arrange const props = createDefaultProps({ disabled: false }) - // Act const { rerender } = render() expect(screen.getByRole('button')).not.toBeDisabled() rerender() - // Assert expect(screen.getByRole('button')).toBeDisabled() }) }) - // ========================================== // Edge Cases - Test boundary conditions and error handling - // ========================================== describe('Edge Cases', () => { it('should handle empty string as currentLanguage', () => { - // Arrange const props = createDefaultProps({ currentLanguage: '' }) - // Act render() // Assert - Component should still render @@ -379,10 +322,8 @@ describe('LanguageSelect', () => { }) it('should handle non-existent language as currentLanguage', () => { - // Arrange const props = createDefaultProps({ currentLanguage: 'NonExistentLanguage' }) - // Act render() // Assert - Should display the value even if not in list @@ -393,19 +334,15 @@ describe('LanguageSelect', () => { // Arrange - Turkish has special character in prompt_name const props = createDefaultProps({ currentLanguage: 'TĂŒrkçe' }) - // Act render() - // Assert expect(screen.getByText('TĂŒrkçe')).toBeInTheDocument() }) it('should handle very long language names', () => { - // Arrange const longLanguageName = 'A'.repeat(100) const props = createDefaultProps({ currentLanguage: longLanguageName }) - // Act render() // Assert - Should not crash and should display the text @@ -413,11 +350,9 @@ describe('LanguageSelect', () => { }) it('should render correct number of language options', () => { - // Arrange const props = createDefaultProps() render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -431,11 +366,9 @@ describe('LanguageSelect', () => { }) it('should only show supported languages in dropdown', () => { - // Arrange const props = createDefaultProps() render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -452,7 +385,6 @@ describe('LanguageSelect', () => { // Arrange - This tests TypeScript boundary, but runtime should not crash const props = createDefaultProps() - // Act render() const button = screen.getByRole('button') fireEvent.click(button) @@ -463,11 +395,9 @@ describe('LanguageSelect', () => { }) it('should maintain selection state visually with check icon', () => { - // Arrange const props = createDefaultProps({ currentLanguage: 'Russian' }) const { container } = render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -478,28 +408,21 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // Accessibility - Basic accessibility checks - // ========================================== describe('Accessibility', () => { it('should have accessible button element', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert const button = screen.getByRole('button') expect(button).toBeInTheDocument() }) it('should have clickable language options', () => { - // Arrange const props = createDefaultProps() render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -509,16 +432,12 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // Integration with Popover - Test Popover behavior - // ========================================== describe('Popover Integration', () => { it('should use manualClose prop on Popover', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) - // Act render() const button = screen.getByRole('button') fireEvent.click(button) @@ -528,11 +447,9 @@ describe('LanguageSelect', () => { }) it('should have correct popup z-index class', () => { - // Arrange const props = createDefaultProps() const { container } = render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -542,12 +459,9 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // Styling Tests - Verify correct CSS classes applied - // ========================================== describe('Styling', () => { it('should apply tertiary button styling', () => { - // Arrange const props = createDefaultProps() const { container } = render() @@ -556,11 +470,9 @@ describe('LanguageSelect', () => { }) it('should apply hover styling class to options', () => { - // Arrange const props = createDefaultProps() const { container } = render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -570,11 +482,9 @@ describe('LanguageSelect', () => { }) it('should apply correct text styling to language options', () => { - // Arrange const props = createDefaultProps() const { container } = render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -584,7 +494,6 @@ describe('LanguageSelect', () => { }) it('should apply disabled styling to icon when disabled', () => { - // Arrange const props = createDefaultProps({ disabled: true }) const { container } = render() diff --git a/web/app/components/datasets/create/step-two/preview-item/index.spec.tsx b/web/app/components/datasets/create/step-two/preview-item/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/create/step-two/preview-item/index.spec.tsx rename to web/app/components/datasets/create/step-two/preview-item/__tests__/index.spec.tsx index c4cdf75480..a246293cbe 100644 --- a/web/app/components/datasets/create/step-two/preview-item/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/preview-item/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ -import type { IPreviewItemProps } from './index' +import type { IPreviewItemProps } from '../index' import { render, screen } from '@testing-library/react' import * as React from 'react' -import PreviewItem, { PreviewType } from './index' +import PreviewItem, { PreviewType } from '../index' // Test data builder for props const createDefaultProps = (overrides?: Partial): IPreviewItemProps => ({ @@ -26,40 +26,29 @@ describe('PreviewItem', () => { vi.clearAllMocks() }) - // ========================================== // Rendering Tests - Verify component renders correctly - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('Test content')).toBeInTheDocument() }) it('should render with TEXT type', () => { - // Arrange const props = createDefaultProps({ content: 'Sample text content' }) - // Act render() - // Assert expect(screen.getByText('Sample text content')).toBeInTheDocument() }) it('should render with QA type', () => { - // Arrange const props = createQAProps() - // Act render() - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() expect(screen.getByText('Test question')).toBeInTheDocument() @@ -67,10 +56,8 @@ describe('PreviewItem', () => { }) it('should render sharp icon (#) with formatted index', () => { - // Arrange const props = createDefaultProps({ index: 5 }) - // Act const { container } = render() // Assert - Index should be padded to 3 digits @@ -81,11 +68,9 @@ describe('PreviewItem', () => { }) it('should render character count for TEXT type', () => { - // Arrange const content = 'Hello World' // 11 characters const props = createDefaultProps({ content }) - // Act render() // Assert - Shows character count with translation key @@ -94,7 +79,6 @@ describe('PreviewItem', () => { }) it('should render character count for QA type', () => { - // Arrange const props = createQAProps({ qa: { question: 'Hello', // 5 characters @@ -102,7 +86,6 @@ describe('PreviewItem', () => { }, }) - // Act render() // Assert - Shows combined character count @@ -110,10 +93,8 @@ describe('PreviewItem', () => { }) it('should render text icon SVG', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() // Assert - Should have SVG icons @@ -122,35 +103,27 @@ describe('PreviewItem', () => { }) }) - // ========================================== // Props Testing - Verify all prop variations work correctly - // ========================================== describe('Props', () => { describe('type prop', () => { it('should render TEXT content when type is TEXT', () => { - // Arrange const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text mode content' }) - // Act render() - // Assert expect(screen.getByText('Text mode content')).toBeInTheDocument() expect(screen.queryByText('Q')).not.toBeInTheDocument() expect(screen.queryByText('A')).not.toBeInTheDocument() }) it('should render QA content when type is QA', () => { - // Arrange const props = createQAProps({ type: PreviewType.QA, qa: { question: 'My question', answer: 'My answer' }, }) - // Act render() - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() expect(screen.getByText('My question')).toBeInTheDocument() @@ -158,24 +131,18 @@ describe('PreviewItem', () => { }) it('should use TEXT as default type when type is "text"', () => { - // Arrange const props = createDefaultProps({ type: 'text' as PreviewType, content: 'Default type content' }) - // Act render() - // Assert expect(screen.getByText('Default type content')).toBeInTheDocument() }) it('should use QA type when type is "QA"', () => { - // Arrange const props = createQAProps({ type: 'QA' as PreviewType }) - // Act render() - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) @@ -191,57 +158,43 @@ describe('PreviewItem', () => { [999, '999'], [1000, '1000'], ])('should format index %i as %s', (index, expected) => { - // Arrange const props = createDefaultProps({ index }) - // Act render() - // Assert expect(screen.getByText(expected)).toBeInTheDocument() }) it('should handle index 0', () => { - // Arrange const props = createDefaultProps({ index: 0 }) - // Act render() - // Assert expect(screen.getByText('000')).toBeInTheDocument() }) it('should handle large index numbers', () => { - // Arrange const props = createDefaultProps({ index: 12345 }) - // Act render() - // Assert expect(screen.getByText('12345')).toBeInTheDocument() }) }) describe('content prop', () => { it('should render content when provided', () => { - // Arrange const props = createDefaultProps({ content: 'Custom content here' }) - // Act render() - // Assert expect(screen.getByText('Custom content here')).toBeInTheDocument() }) it('should handle multiline content', () => { - // Arrange const multilineContent = 'Line 1\nLine 2\nLine 3' const props = createDefaultProps({ content: multilineContent }) - // Act const { container } = render() // Assert - Check content is rendered (multiline text is in pre-line div) @@ -252,10 +205,8 @@ describe('PreviewItem', () => { }) it('should preserve whitespace with pre-line style', () => { - // Arrange const props = createDefaultProps({ content: 'Text with spaces' }) - // Act const { container } = render() // Assert - Check for whiteSpace: pre-line style @@ -266,7 +217,6 @@ describe('PreviewItem', () => { describe('qa prop', () => { it('should render question and answer when qa is provided', () => { - // Arrange const props = createQAProps({ qa: { question: 'What is testing?', @@ -274,28 +224,22 @@ describe('PreviewItem', () => { }, }) - // Act render() - // Assert expect(screen.getByText('What is testing?')).toBeInTheDocument() expect(screen.getByText('Testing is verification.')).toBeInTheDocument() }) it('should render Q and A labels', () => { - // Arrange const props = createQAProps() - // Act render() - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) it('should handle multiline question', () => { - // Arrange const props = createQAProps({ qa: { question: 'Question line 1\nQuestion line 2', @@ -303,7 +247,6 @@ describe('PreviewItem', () => { }, }) - // Act const { container } = render() // Assert - Check content is in pre-line div @@ -314,7 +257,6 @@ describe('PreviewItem', () => { }) it('should handle multiline answer', () => { - // Arrange const props = createQAProps({ qa: { question: 'Question', @@ -322,7 +264,6 @@ describe('PreviewItem', () => { }, }) - // Act const { container } = render() // Assert - Check content is in pre-line div @@ -334,9 +275,7 @@ describe('PreviewItem', () => { }) }) - // ========================================== // Component Memoization - Test React.memo behavior - // ========================================== describe('Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - Check component has memo wrapper @@ -344,7 +283,6 @@ describe('PreviewItem', () => { }) it('should not re-render when props remain the same', () => { - // Arrange const props = createDefaultProps() const renderSpy = vi.fn() @@ -355,7 +293,6 @@ describe('PreviewItem', () => { } const MemoizedTracked = React.memo(TrackedPreviewItem) - // Act const { rerender } = render() rerender() @@ -364,77 +301,61 @@ describe('PreviewItem', () => { }) it('should re-render when content changes', () => { - // Arrange const props = createDefaultProps({ content: 'Initial content' }) - // Act const { rerender } = render() expect(screen.getByText('Initial content')).toBeInTheDocument() rerender() - // Assert expect(screen.getByText('Updated content')).toBeInTheDocument() }) it('should re-render when index changes', () => { - // Arrange const props = createDefaultProps({ index: 1 }) - // Act const { rerender } = render() expect(screen.getByText('001')).toBeInTheDocument() rerender() - // Assert expect(screen.getByText('099')).toBeInTheDocument() }) it('should re-render when type changes', () => { - // Arrange const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text content' }) - // Act const { rerender } = render() expect(screen.getByText('Text content')).toBeInTheDocument() expect(screen.queryByText('Q')).not.toBeInTheDocument() rerender() - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) it('should re-render when qa prop changes', () => { - // Arrange const props = createQAProps({ qa: { question: 'Original question', answer: 'Original answer' }, }) - // Act const { rerender } = render() expect(screen.getByText('Original question')).toBeInTheDocument() rerender() - // Assert expect(screen.getByText('New question')).toBeInTheDocument() expect(screen.getByText('New answer')).toBeInTheDocument() }) }) - // ========================================== // Edge Cases - Test boundary conditions and error handling - // ========================================== describe('Edge Cases', () => { describe('Empty/Undefined values', () => { it('should handle undefined content gracefully', () => { - // Arrange const props = createDefaultProps({ content: undefined }) - // Act render() // Assert - Should show 0 characters (use more specific text match) @@ -442,10 +363,8 @@ describe('PreviewItem', () => { }) it('should handle empty string content', () => { - // Arrange const props = createDefaultProps({ content: '' }) - // Act render() // Assert - Should show 0 characters (use more specific text match) @@ -453,14 +372,12 @@ describe('PreviewItem', () => { }) it('should handle undefined qa gracefully', () => { - // Arrange const props: IPreviewItemProps = { type: PreviewType.QA, index: 1, qa: undefined, } - // Act render() // Assert - Should render Q and A labels but with empty content @@ -471,7 +388,6 @@ describe('PreviewItem', () => { }) it('should handle undefined question in qa', () => { - // Arrange const props: IPreviewItemProps = { type: PreviewType.QA, index: 1, @@ -481,15 +397,12 @@ describe('PreviewItem', () => { }, } - // Act render() - // Assert expect(screen.getByText('Only answer')).toBeInTheDocument() }) it('should handle undefined answer in qa', () => { - // Arrange const props: IPreviewItemProps = { type: PreviewType.QA, index: 1, @@ -499,20 +412,16 @@ describe('PreviewItem', () => { }, } - // Act render() - // Assert expect(screen.getByText('Only question')).toBeInTheDocument() }) it('should handle empty question and answer strings', () => { - // Arrange const props = createQAProps({ qa: { question: '', answer: '' }, }) - // Act render() // Assert - Should show 0 characters (use more specific text match) @@ -527,10 +436,8 @@ describe('PreviewItem', () => { // Arrange - 'Test' has 4 characters const props = createDefaultProps({ content: 'Test' }) - // Act render() - // Assert expect(screen.getByText(/4/)).toBeInTheDocument() }) @@ -540,10 +447,8 @@ describe('PreviewItem', () => { qa: { question: 'ABC', answer: 'DEFGH' }, }) - // Act render() - // Assert expect(screen.getByText(/8/)).toBeInTheDocument() }) @@ -551,10 +456,8 @@ describe('PreviewItem', () => { // Arrange - Content with special characters const props = createDefaultProps({ content: 'äœ ć„œäž–ç•Œ' }) // 4 Chinese characters - // Act render() - // Assert expect(screen.getByText(/4/)).toBeInTheDocument() }) @@ -562,10 +465,8 @@ describe('PreviewItem', () => { // Arrange - 'a\nb' has 3 characters const props = createDefaultProps({ content: 'a\nb' }) - // Act render() - // Assert expect(screen.getByText(/3/)).toBeInTheDocument() }) @@ -573,21 +474,17 @@ describe('PreviewItem', () => { // Arrange - 'a b' has 3 characters const props = createDefaultProps({ content: 'a b' }) - // Act render() - // Assert expect(screen.getByText(/3/)).toBeInTheDocument() }) }) describe('Boundary conditions', () => { it('should handle very long content', () => { - // Arrange const longContent = 'A'.repeat(10000) const props = createDefaultProps({ content: longContent }) - // Act render() // Assert - Should show correct character count @@ -595,21 +492,16 @@ describe('PreviewItem', () => { }) it('should handle very long index', () => { - // Arrange const props = createDefaultProps({ index: 999999999 }) - // Act render() - // Assert expect(screen.getByText('999999999')).toBeInTheDocument() }) it('should handle negative index', () => { - // Arrange const props = createDefaultProps({ index: -1 }) - // Act render() // Assert - padStart pads from the start, so -1 becomes 0-1 @@ -617,21 +509,16 @@ describe('PreviewItem', () => { }) it('should handle content with only whitespace', () => { - // Arrange const props = createDefaultProps({ content: ' ' }) // 3 spaces - // Act render() - // Assert expect(screen.getByText(/3/)).toBeInTheDocument() }) it('should handle content with HTML-like characters', () => { - // Arrange const props = createDefaultProps({ content: '
Test
' }) - // Act render() // Assert - Should render as text, not HTML @@ -642,7 +529,6 @@ describe('PreviewItem', () => { // Arrange - Emojis can have complex character lengths const props = createDefaultProps({ content: '😀👍' }) - // Act render() // Assert - Emoji length depends on JS string length @@ -660,17 +546,14 @@ describe('PreviewItem', () => { qa: { question: 'Should not show', answer: 'Also should not show' }, } - // Act render() - // Assert expect(screen.getByText('Text content')).toBeInTheDocument() expect(screen.queryByText('Should not show')).not.toBeInTheDocument() expect(screen.queryByText('Also should not show')).not.toBeInTheDocument() }) it('should use content length for TEXT type even when qa is provided', () => { - // Arrange const props: IPreviewItemProps = { type: PreviewType.TEXT, index: 1, @@ -678,7 +561,6 @@ describe('PreviewItem', () => { qa: { question: 'Question', answer: 'Answer' }, // Would be 14 characters if used } - // Act render() // Assert - Should show 2, not 14 @@ -686,7 +568,6 @@ describe('PreviewItem', () => { }) it('should ignore content prop when type is QA', () => { - // Arrange const props: IPreviewItemProps = { type: PreviewType.QA, index: 1, @@ -694,10 +575,8 @@ describe('PreviewItem', () => { qa: { question: 'Q text', answer: 'A text' }, } - // Act render() - // Assert expect(screen.queryByText('Should not display')).not.toBeInTheDocument() expect(screen.getByText('Q text')).toBeInTheDocument() expect(screen.getByText('A text')).toBeInTheDocument() @@ -705,9 +584,7 @@ describe('PreviewItem', () => { }) }) - // ========================================== // PreviewType Enum - Test exported enum values - // ========================================== describe('PreviewType Enum', () => { it('should have TEXT value as "text"', () => { expect(PreviewType.TEXT).toBe('text') @@ -718,27 +595,20 @@ describe('PreviewItem', () => { }) }) - // ========================================== // Styling Tests - Verify correct CSS classes applied - // ========================================== describe('Styling', () => { it('should have rounded container with gray background', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() - // Assert const rootDiv = container.firstChild as HTMLElement expect(rootDiv).toHaveClass('rounded-xl', 'bg-gray-50', 'p-4') }) it('should have proper header styling', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() // Assert - Check header div styling @@ -747,53 +617,40 @@ describe('PreviewItem', () => { }) it('should have index badge styling', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() - // Assert const indexBadge = container.querySelector('.border.border-gray-200') expect(indexBadge).toBeInTheDocument() expect(indexBadge).toHaveClass('rounded-md', 'italic', 'font-medium') }) it('should have content area with line-clamp', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() - // Assert const contentArea = container.querySelector('.line-clamp-6') expect(contentArea).toBeInTheDocument() expect(contentArea).toHaveClass('max-h-[120px]', 'overflow-hidden') }) it('should have Q/A labels with gray color', () => { - // Arrange const props = createQAProps() - // Act const { container } = render() - // Assert const labels = container.querySelectorAll('.text-gray-400') expect(labels.length).toBeGreaterThanOrEqual(2) // Q and A labels }) }) - // ========================================== // i18n Translation - Test translation integration - // ========================================== describe('i18n Translation', () => { it('should use translation key for characters label', () => { - // Arrange const props = createDefaultProps({ content: 'Test' }) - // Act render() // Assert - The mock returns the key as-is diff --git a/web/app/components/datasets/create/stepper/index.spec.tsx b/web/app/components/datasets/create/stepper/__tests__/index.spec.tsx similarity index 82% rename from web/app/components/datasets/create/stepper/index.spec.tsx rename to web/app/components/datasets/create/stepper/__tests__/index.spec.tsx index 3a66a5f8f4..a3cf5742b8 100644 --- a/web/app/components/datasets/create/stepper/index.spec.tsx +++ b/web/app/components/datasets/create/stepper/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { StepperProps } from './index' -import type { Step, StepperStepProps } from './step' +import type { StepperProps } from '../index' +import type { Step, StepperStepProps } from '../step' import { render, screen } from '@testing-library/react' -import { Stepper } from './index' -import { StepperStep } from './step' +import { Stepper } from '../index' +import { StepperStep } from '../step' // Test data factory for creating steps const createStep = (overrides: Partial = {}): Step => ({ @@ -34,44 +34,33 @@ const renderStepperStep = (props: Partial = {}) => { return render() } -// ============================================================================ // Stepper Component Tests -// ============================================================================ describe('Stepper', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- // Rendering Tests - Verify component renders properly with various inputs - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderStepper() - // Assert expect(screen.getByText('Step 1')).toBeInTheDocument() }) it('should render all step names', () => { - // Arrange const steps = createSteps(3, 'Custom Step') - // Act renderStepper({ steps }) - // Assert expect(screen.getByText('Custom Step 1')).toBeInTheDocument() expect(screen.getByText('Custom Step 2')).toBeInTheDocument() expect(screen.getByText('Custom Step 3')).toBeInTheDocument() }) it('should render dividers between steps', () => { - // Arrange const steps = createSteps(3) - // Act const { container } = renderStepper({ steps }) // Assert - Should have 2 dividers for 3 steps @@ -80,10 +69,8 @@ describe('Stepper', () => { }) it('should not render divider after last step', () => { - // Arrange const steps = createSteps(2) - // Act const { container } = renderStepper({ steps }) // Assert - Should have 1 divider for 2 steps @@ -92,28 +79,21 @@ describe('Stepper', () => { }) it('should render with flex container layout', () => { - // Arrange & Act const { container } = renderStepper() - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex', 'items-center', 'gap-3') }) }) - // -------------------------------------------------------------------------- // Props Testing - Test all prop variations and combinations - // -------------------------------------------------------------------------- describe('Props', () => { describe('steps prop', () => { it('should render correct number of steps', () => { - // Arrange const steps = createSteps(5) - // Act renderStepper({ steps }) - // Assert expect(screen.getByText('Step 1')).toBeInTheDocument() expect(screen.getByText('Step 2')).toBeInTheDocument() expect(screen.getByText('Step 3')).toBeInTheDocument() @@ -122,13 +102,10 @@ describe('Stepper', () => { }) it('should handle single step correctly', () => { - // Arrange const steps = [createStep({ name: 'Only Step' })] - // Act const { container } = renderStepper({ steps, activeIndex: 0 }) - // Assert expect(screen.getByText('Only Step')).toBeInTheDocument() // No dividers for single step const dividers = container.querySelectorAll('.bg-divider-deep') @@ -136,29 +113,23 @@ describe('Stepper', () => { }) it('should handle steps with long names', () => { - // Arrange const longName = 'This is a very long step name that might overflow' const steps = [createStep({ name: longName })] - // Act renderStepper({ steps, activeIndex: 0 }) - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle steps with special characters', () => { - // Arrange const steps = [ createStep({ name: 'Step & Configuration' }), createStep({ name: 'Step ' }), createStep({ name: 'Step "Complete"' }), ] - // Act renderStepper({ steps, activeIndex: 0 }) - // Assert expect(screen.getByText('Step & Configuration')).toBeInTheDocument() expect(screen.getByText('Step ')).toBeInTheDocument() expect(screen.getByText('Step "Complete"')).toBeInTheDocument() @@ -167,7 +138,6 @@ describe('Stepper', () => { describe('activeIndex prop', () => { it('should highlight first step when activeIndex is 0', () => { - // Arrange & Act renderStepper({ activeIndex: 0 }) // Assert - First step should show "STEP 1" label @@ -175,7 +145,6 @@ describe('Stepper', () => { }) it('should highlight second step when activeIndex is 1', () => { - // Arrange & Act renderStepper({ activeIndex: 1 }) // Assert - Second step should show "STEP 2" label @@ -183,10 +152,8 @@ describe('Stepper', () => { }) it('should highlight last step when activeIndex equals steps length - 1', () => { - // Arrange const steps = createSteps(3) - // Act renderStepper({ steps, activeIndex: 2 }) // Assert - Third step should show "STEP 3" label @@ -194,10 +161,8 @@ describe('Stepper', () => { }) it('should show completed steps with number only (no STEP prefix)', () => { - // Arrange const steps = createSteps(3) - // Act renderStepper({ steps, activeIndex: 2 }) // Assert - Completed steps show just the number @@ -207,10 +172,8 @@ describe('Stepper', () => { }) it('should show disabled steps with number only (no STEP prefix)', () => { - // Arrange const steps = createSteps(3) - // Act renderStepper({ steps, activeIndex: 0 }) // Assert - Disabled steps show just the number @@ -221,12 +184,9 @@ describe('Stepper', () => { }) }) - // -------------------------------------------------------------------------- // Edge Cases - Test boundary conditions and unexpected inputs - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty steps array', () => { - // Arrange & Act const { container } = renderStepper({ steps: [] }) // Assert - Container should render but be empty @@ -235,7 +195,6 @@ describe('Stepper', () => { }) it('should handle activeIndex greater than steps length', () => { - // Arrange const steps = createSteps(2) // Act - activeIndex 5 is beyond array bounds @@ -247,7 +206,6 @@ describe('Stepper', () => { }) it('should handle negative activeIndex', () => { - // Arrange const steps = createSteps(2) // Act - negative activeIndex @@ -259,13 +217,10 @@ describe('Stepper', () => { }) it('should handle large number of steps', () => { - // Arrange const steps = createSteps(10) - // Act const { container } = renderStepper({ steps, activeIndex: 5 }) - // Assert expect(screen.getByText('STEP 6')).toBeInTheDocument() // Should have 9 dividers for 10 steps const dividers = container.querySelectorAll('.bg-divider-deep') @@ -273,10 +228,8 @@ describe('Stepper', () => { }) it('should handle steps with empty name', () => { - // Arrange const steps = [createStep({ name: '' })] - // Act const { container } = renderStepper({ steps, activeIndex: 0 }) // Assert - Should still render the step structure @@ -285,18 +238,13 @@ describe('Stepper', () => { }) }) - // -------------------------------------------------------------------------- // Integration - Test step state combinations - // -------------------------------------------------------------------------- describe('Step States', () => { it('should render mixed states: completed, active, disabled', () => { - // Arrange const steps = createSteps(5) - // Act renderStepper({ steps, activeIndex: 2 }) - // Assert // Steps 1-2 are completed (show number only) expect(screen.getByText('1')).toBeInTheDocument() expect(screen.getByText('2')).toBeInTheDocument() @@ -308,7 +256,6 @@ describe('Stepper', () => { }) it('should transition through all states correctly', () => { - // Arrange const steps = createSteps(3) // Act & Assert - Step 1 active @@ -329,80 +276,59 @@ describe('Stepper', () => { }) }) -// ============================================================================ // StepperStep Component Tests -// ============================================================================ describe('StepperStep', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderStepperStep() - // Assert expect(screen.getByText('Test Step')).toBeInTheDocument() }) it('should render step name', () => { - // Arrange & Act renderStepperStep({ name: 'Configure Dataset' }) - // Assert expect(screen.getByText('Configure Dataset')).toBeInTheDocument() }) it('should render with flex container layout', () => { - // Arrange & Act const { container } = renderStepperStep() - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex', 'items-center', 'gap-2') }) }) - // -------------------------------------------------------------------------- // Active State Tests - // -------------------------------------------------------------------------- describe('Active State', () => { it('should show STEP prefix when active', () => { - // Arrange & Act renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert expect(screen.getByText('STEP 1')).toBeInTheDocument() }) it('should apply active styles to label container', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert const labelContainer = container.querySelector('.bg-state-accent-solid') expect(labelContainer).toBeInTheDocument() expect(labelContainer).toHaveClass('px-2') }) it('should apply active text color to label', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert const label = container.querySelector('.text-text-primary-on-surface') expect(label).toBeInTheDocument() }) it('should apply accent text color to name when active', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert const nameElement = container.querySelector('.text-text-accent') expect(nameElement).toBeInTheDocument() expect(nameElement).toHaveClass('system-xs-semibold-uppercase') @@ -421,105 +347,79 @@ describe('StepperStep', () => { }) }) - // -------------------------------------------------------------------------- // Completed State Tests (index < activeIndex) - // -------------------------------------------------------------------------- describe('Completed State', () => { it('should show number only when completed (not active)', () => { - // Arrange & Act renderStepperStep({ index: 0, activeIndex: 1 }) - // Assert expect(screen.getByText('1')).toBeInTheDocument() expect(screen.queryByText('STEP 1')).not.toBeInTheDocument() }) it('should apply completed styles to label container', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 1 }) - // Assert const labelContainer = container.querySelector('.border-text-quaternary') expect(labelContainer).toBeInTheDocument() expect(labelContainer).toHaveClass('w-5') }) it('should apply tertiary text color to label when completed', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 1 }) - // Assert const label = container.querySelector('.text-text-tertiary') expect(label).toBeInTheDocument() }) it('should apply tertiary text color to name when completed', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 2 }) - // Assert const nameElements = container.querySelectorAll('.text-text-tertiary') expect(nameElements.length).toBeGreaterThan(0) }) }) - // -------------------------------------------------------------------------- // Disabled State Tests (index > activeIndex) - // -------------------------------------------------------------------------- describe('Disabled State', () => { it('should show number only when disabled', () => { - // Arrange & Act renderStepperStep({ index: 2, activeIndex: 0 }) - // Assert expect(screen.getByText('3')).toBeInTheDocument() expect(screen.queryByText('STEP 3')).not.toBeInTheDocument() }) it('should apply disabled styles to label container', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) - // Assert const labelContainer = container.querySelector('.border-divider-deep') expect(labelContainer).toBeInTheDocument() expect(labelContainer).toHaveClass('w-5') }) it('should apply quaternary text color to label when disabled', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) - // Assert const label = container.querySelector('.text-text-quaternary') expect(label).toBeInTheDocument() }) it('should apply quaternary text color to name when disabled', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) - // Assert const nameElements = container.querySelectorAll('.text-text-quaternary') expect(nameElements.length).toBeGreaterThan(0) }) }) - // -------------------------------------------------------------------------- - // Props Testing - // -------------------------------------------------------------------------- describe('Props', () => { describe('name prop', () => { it('should render provided name', () => { - // Arrange & Act renderStepperStep({ name: 'Custom Name' }) - // Assert expect(screen.getByText('Custom Name')).toBeInTheDocument() }) it('should handle empty name', () => { - // Arrange & Act const { container } = renderStepperStep({ name: '' }) // Assert - Label should still render @@ -528,36 +428,28 @@ describe('StepperStep', () => { }) it('should handle name with whitespace', () => { - // Arrange & Act renderStepperStep({ name: ' Padded Name ' }) - // Assert expect(screen.getByText('Padded Name')).toBeInTheDocument() }) }) describe('index prop', () => { it('should display correct 1-based number for index 0', () => { - // Arrange & Act renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert expect(screen.getByText('STEP 1')).toBeInTheDocument() }) it('should display correct 1-based number for index 9', () => { - // Arrange & Act renderStepperStep({ index: 9, activeIndex: 9 }) - // Assert expect(screen.getByText('STEP 10')).toBeInTheDocument() }) it('should handle large index values', () => { - // Arrange & Act renderStepperStep({ index: 99, activeIndex: 99 }) - // Assert expect(screen.getByText('STEP 100')).toBeInTheDocument() }) }) @@ -581,20 +473,14 @@ describe('StepperStep', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle zero index correctly', () => { - // Arrange & Act renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert expect(screen.getByText('STEP 1')).toBeInTheDocument() }) it('should handle negative activeIndex', () => { - // Arrange & Act renderStepperStep({ index: 0, activeIndex: -1 }) // Assert - Step should be disabled (index > activeIndex) @@ -602,7 +488,6 @@ describe('StepperStep', () => { }) it('should handle equal boundary (index equals activeIndex)', () => { - // Arrange & Act renderStepperStep({ index: 5, activeIndex: 5 }) // Assert - Should be active @@ -610,7 +495,6 @@ describe('StepperStep', () => { }) it('should handle name with HTML-like content safely', () => { - // Arrange & Act renderStepperStep({ name: '' }) // Assert - Should render as text, not execute @@ -618,73 +502,57 @@ describe('StepperStep', () => { }) it('should handle name with unicode characters', () => { - // Arrange & Act renderStepperStep({ name: 'Step æ•°æź 🚀' }) - // Assert expect(screen.getByText('Step æ•°æź 🚀')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Style Classes Verification - // -------------------------------------------------------------------------- describe('Style Classes', () => { it('should apply correct typography classes to label', () => { - // Arrange & Act const { container } = renderStepperStep() - // Assert const label = container.querySelector('.system-2xs-semibold-uppercase') expect(label).toBeInTheDocument() }) it('should apply correct typography classes to name', () => { - // Arrange & Act const { container } = renderStepperStep() - // Assert const name = container.querySelector('.system-xs-medium-uppercase') expect(name).toBeInTheDocument() }) it('should have rounded pill shape for label container', () => { - // Arrange & Act const { container } = renderStepperStep() - // Assert const labelContainer = container.querySelector('.rounded-3xl') expect(labelContainer).toBeInTheDocument() }) it('should apply h-5 height to label container', () => { - // Arrange & Act const { container } = renderStepperStep() - // Assert const labelContainer = container.querySelector('.h-5') expect(labelContainer).toBeInTheDocument() }) }) }) -// ============================================================================ // Integration Tests - Stepper and StepperStep working together -// ============================================================================ describe('Stepper Integration', () => { beforeEach(() => { vi.clearAllMocks() }) it('should pass correct props to each StepperStep', () => { - // Arrange const steps = [ createStep({ name: 'First' }), createStep({ name: 'Second' }), createStep({ name: 'Third' }), ] - // Act renderStepper({ steps, activeIndex: 1 }) // Assert - Each step receives correct index and displays correctly @@ -697,10 +565,8 @@ describe('Stepper Integration', () => { }) it('should maintain correct visual hierarchy across steps', () => { - // Arrange const steps = createSteps(4) - // Act const { container } = renderStepper({ steps, activeIndex: 2 }) // Assert - Check visual hierarchy @@ -718,10 +584,8 @@ describe('Stepper Integration', () => { }) it('should render correctly with dynamic step updates', () => { - // Arrange const initialSteps = createSteps(2) - // Act const { rerender } = render() expect(screen.getByText('Step 1')).toBeInTheDocument() expect(screen.getByText('Step 2')).toBeInTheDocument() @@ -730,7 +594,6 @@ describe('Stepper Integration', () => { const updatedSteps = createSteps(4) rerender() - // Assert expect(screen.getByText('STEP 3')).toBeInTheDocument() expect(screen.getByText('Step 4')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/create/stepper/__tests__/step.spec.tsx b/web/app/components/datasets/create/stepper/__tests__/step.spec.tsx new file mode 100644 index 0000000000..6d046bb9c9 --- /dev/null +++ b/web/app/components/datasets/create/stepper/__tests__/step.spec.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { StepperStep } from '../step' + +describe('StepperStep', () => { + it('should render step name', () => { + render() + expect(screen.getByText('Configure')).toBeInTheDocument() + }) + + it('should show "STEP N" label for active step', () => { + render() + expect(screen.getByText('STEP 2')).toBeInTheDocument() + }) + + it('should show just number for non-active step', () => { + render() + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('should apply accent style for active step', () => { + render() + const nameEl = screen.getByText('Step A') + expect(nameEl.className).toContain('text-text-accent') + }) + + it('should apply disabled style for future step', () => { + render() + const nameEl = screen.getByText('Step C') + expect(nameEl.className).toContain('text-text-quaternary') + }) +}) diff --git a/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx b/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx similarity index 85% rename from web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx rename to web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx index 897c965c96..5dc30be00f 100644 --- a/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx +++ b/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type { MockInstance } from 'vitest' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import StopEmbeddingModal from './index' +import StopEmbeddingModal from '../index' // Helper type for component props type StopEmbeddingModalProps = { @@ -23,9 +23,7 @@ const renderStopEmbeddingModal = (props: Partial = {}) } } -// ============================================================================ // StopEmbeddingModal Component Tests -// ============================================================================ describe('StopEmbeddingModal', () => { // Suppress Headless UI warnings in tests // These warnings are from the library's internal behavior, not our code @@ -37,69 +35,54 @@ describe('StopEmbeddingModal', () => { consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn()) }) + beforeEach(() => { + vi.clearAllMocks() + }) + afterAll(() => { consoleWarnSpy.mockRestore() consoleErrorSpy.mockRestore() }) - beforeEach(() => { - vi.clearAllMocks() - }) - - // -------------------------------------------------------------------------- // Rendering Tests - Verify component renders properly - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing when show is true', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() }) it('should render modal title', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() }) it('should render modal content', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() }) it('should render confirm button with correct text', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument() }) it('should render cancel button with correct text', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument() }) it('should not render modal content when show is false', () => { - // Arrange & Act renderStopEmbeddingModal({ show: false }) - // Assert expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() }) it('should render buttons in correct order (cancel first, then confirm)', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) // Assert - Due to flex-row-reverse, confirm appears first visually but cancel is first in DOM @@ -108,25 +91,20 @@ describe('StopEmbeddingModal', () => { }) it('should render confirm button with primary variant styling', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') expect(confirmButton).toHaveClass('ml-2', 'w-24') }) it('should render cancel button with default styling', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') expect(cancelButton).toHaveClass('w-24') }) it('should render all modal elements', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) // Assert - Modal should contain title, content, and buttons @@ -137,39 +115,30 @@ describe('StopEmbeddingModal', () => { }) }) - // -------------------------------------------------------------------------- // Props Testing - Test all prop variations - // -------------------------------------------------------------------------- describe('Props', () => { describe('show prop', () => { it('should show modal when show is true', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() }) it('should hide modal when show is false', () => { - // Arrange & Act renderStopEmbeddingModal({ show: false }) - // Assert expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() }) it('should use default value false when show is not provided', () => { - // Arrange & Act const onConfirm = vi.fn() const onHide = vi.fn() render() - // Assert expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() }) it('should toggle visibility when show prop changes to true', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() @@ -193,10 +162,8 @@ describe('StopEmbeddingModal', () => { describe('onConfirm prop', () => { it('should accept onConfirm callback function', () => { - // Arrange const onConfirm = vi.fn() - // Act renderStopEmbeddingModal({ onConfirm }) // Assert - No errors thrown @@ -206,10 +173,8 @@ describe('StopEmbeddingModal', () => { describe('onHide prop', () => { it('should accept onHide callback function', () => { - // Arrange const onHide = vi.fn() - // Act renderStopEmbeddingModal({ onHide }) // Assert - No errors thrown @@ -218,51 +183,41 @@ describe('StopEmbeddingModal', () => { }) }) - // -------------------------------------------------------------------------- // User Interactions Tests - Test click events and event handlers - // -------------------------------------------------------------------------- describe('User Interactions', () => { describe('Confirm Button', () => { it('should call onConfirm when confirm button is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledTimes(1) }) it('should call onHide when confirm button is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(onHide).toHaveBeenCalledTimes(1) }) it('should call both onConfirm and onHide in correct order when confirm button is clicked', async () => { - // Arrange const callOrder: string[] = [] const onConfirm = vi.fn(() => callOrder.push('confirm')) const onHide = vi.fn(() => callOrder.push('hide')) renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) @@ -273,12 +228,10 @@ describe('StopEmbeddingModal', () => { }) it('should handle multiple clicks on confirm button', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) @@ -286,7 +239,6 @@ describe('StopEmbeddingModal', () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledTimes(3) expect(onHide).toHaveBeenCalledTimes(3) }) @@ -294,51 +246,42 @@ describe('StopEmbeddingModal', () => { describe('Cancel Button', () => { it('should call onHide when cancel button is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') await act(async () => { fireEvent.click(cancelButton) }) - // Assert expect(onHide).toHaveBeenCalledTimes(1) }) it('should not call onConfirm when cancel button is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') await act(async () => { fireEvent.click(cancelButton) }) - // Assert expect(onConfirm).not.toHaveBeenCalled() }) it('should handle multiple clicks on cancel button', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') await act(async () => { fireEvent.click(cancelButton) fireEvent.click(cancelButton) }) - // Assert expect(onHide).toHaveBeenCalledTimes(2) expect(onConfirm).not.toHaveBeenCalled() }) @@ -346,7 +289,6 @@ describe('StopEmbeddingModal', () => { describe('Close Icon', () => { it('should call onHide when close span is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) @@ -362,7 +304,6 @@ describe('StopEmbeddingModal', () => { fireEvent.click(closeSpan) }) - // Assert expect(onHide).toHaveBeenCalledTimes(1) } else { @@ -372,12 +313,10 @@ describe('StopEmbeddingModal', () => { }) it('should not call onConfirm when close span is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const spans = container.querySelectorAll('span') const closeSpan = Array.from(spans).find(span => span.className && span.getAttribute('class')?.includes('close'), @@ -388,7 +327,6 @@ describe('StopEmbeddingModal', () => { fireEvent.click(closeSpan) }) - // Assert expect(onConfirm).not.toHaveBeenCalled() } }) @@ -396,7 +334,6 @@ describe('StopEmbeddingModal', () => { describe('Different Close Methods', () => { it('should distinguish between confirm and cancel actions', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) @@ -407,11 +344,9 @@ describe('StopEmbeddingModal', () => { fireEvent.click(cancelButton) }) - // Assert expect(onConfirm).not.toHaveBeenCalled() expect(onHide).toHaveBeenCalledTimes(1) - // Reset vi.clearAllMocks() // Act - Click confirm @@ -420,19 +355,15 @@ describe('StopEmbeddingModal', () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledTimes(1) expect(onHide).toHaveBeenCalledTimes(1) }) }) }) - // -------------------------------------------------------------------------- // Edge Cases Tests - Test null, undefined, empty values and boundaries - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle rapid confirm button clicks', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) @@ -444,13 +375,11 @@ describe('StopEmbeddingModal', () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledTimes(10) expect(onHide).toHaveBeenCalledTimes(10) }) it('should handle rapid cancel button clicks', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) @@ -462,19 +391,16 @@ describe('StopEmbeddingModal', () => { fireEvent.click(cancelButton) }) - // Assert expect(onHide).toHaveBeenCalledTimes(10) expect(onConfirm).not.toHaveBeenCalled() }) it('should handle callbacks being replaced', async () => { - // Arrange const onConfirm1 = vi.fn() const onHide1 = vi.fn() const onConfirm2 = vi.fn() const onHide2 = vi.fn() - // Act const { rerender } = render( , ) @@ -484,7 +410,6 @@ describe('StopEmbeddingModal', () => { rerender() }) - // Click confirm with new callbacks const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) @@ -498,7 +423,6 @@ describe('StopEmbeddingModal', () => { }) it('should render with all required props', () => { - // Arrange & Act render( { />, ) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Layout and Styling Tests - Verify correct structure - // -------------------------------------------------------------------------- describe('Layout and Styling', () => { it('should have buttons container with flex-row-reverse', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const buttons = screen.getAllByRole('button') expect(buttons[0].closest('div')).toHaveClass('flex', 'flex-row-reverse') }) it('should render title and content elements', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() }) it('should render two buttons', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(2) }) }) - // -------------------------------------------------------------------------- // submit Function Tests - Test the internal submit function behavior - // -------------------------------------------------------------------------- describe('submit Function', () => { it('should execute onConfirm first then onHide', async () => { - // Arrange let confirmTime = 0 let hideTime = 0 let counter = 0 @@ -562,73 +474,59 @@ describe('StopEmbeddingModal', () => { }) renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(confirmTime).toBe(1) expect(hideTime).toBe(2) }) it('should call both callbacks exactly once per click', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledTimes(1) expect(onHide).toHaveBeenCalledTimes(1) }) it('should pass no arguments to onConfirm', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledWith() }) it('should pass no arguments to onHide when called from submit', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(onHide).toHaveBeenCalledWith() }) }) - // -------------------------------------------------------------------------- // Modal Integration Tests - Verify Modal component integration - // -------------------------------------------------------------------------- describe('Modal Integration', () => { it('should pass show prop to Modal as isShow', async () => { - // Arrange & Act const { rerender } = render( , ) @@ -648,15 +546,10 @@ describe('StopEmbeddingModal', () => { }) }) - // -------------------------------------------------------------------------- - // Accessibility Tests - // -------------------------------------------------------------------------- describe('Accessibility', () => { it('should have buttons that are focusable', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const buttons = screen.getAllByRole('button') buttons.forEach((button) => { expect(button).not.toHaveAttribute('tabindex', '-1') @@ -664,19 +557,15 @@ describe('StopEmbeddingModal', () => { }) it('should have semantic button elements', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(2) }) it('should have accessible text content', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeVisible() expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeVisible() expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeVisible() @@ -684,12 +573,9 @@ describe('StopEmbeddingModal', () => { }) }) - // -------------------------------------------------------------------------- // Component Lifecycle Tests - // -------------------------------------------------------------------------- describe('Component Lifecycle', () => { it('should unmount cleanly', () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide }) @@ -699,12 +585,10 @@ describe('StopEmbeddingModal', () => { }) it('should not call callbacks after unmount', () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide }) - // Act unmount() // Assert - No callbacks should be called after unmount @@ -713,7 +597,6 @@ describe('StopEmbeddingModal', () => { }) it('should re-render correctly when props update', async () => { - // Arrange const onConfirm1 = vi.fn() const onHide1 = vi.fn() const onConfirm2 = vi.fn() diff --git a/web/app/components/datasets/create/top-bar/index.spec.tsx b/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx similarity index 83% rename from web/app/components/datasets/create/top-bar/index.spec.tsx rename to web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx index ec16d7b892..4fc8d1852b 100644 --- a/web/app/components/datasets/create/top-bar/index.spec.tsx +++ b/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ -import type { TopBarProps } from './index' +import type { TopBarProps } from '../index' import { render, screen } from '@testing-library/react' -import { TopBar } from './index' +import { TopBar } from '../index' // Mock next/link to capture href values vi.mock('next/link', () => ({ @@ -23,31 +23,23 @@ const renderTopBar = (props: Partial = {}) => { } } -// ============================================================================ // TopBar Component Tests -// ============================================================================ describe('TopBar', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- // Rendering Tests - Verify component renders properly - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderTopBar() - // Assert expect(screen.getByTestId('back-link')).toBeInTheDocument() }) it('should render back link with arrow icon', () => { - // Arrange & Act const { container } = renderTopBar() - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toBeInTheDocument() // Check for the arrow icon (svg element) @@ -56,15 +48,12 @@ describe('TopBar', () => { }) it('should render fallback route text', () => { - // Arrange & Act renderTopBar() - // Assert expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument() }) it('should render Stepper component with 3 steps', () => { - // Arrange & Act renderTopBar({ activeIndex: 0 }) // Assert - Check for step translations @@ -74,10 +63,8 @@ describe('TopBar', () => { }) it('should apply default container classes', () => { - // Arrange & Act const { container } = renderTopBar() - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('relative') expect(wrapper).toHaveClass('flex') @@ -90,25 +77,19 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- // Props Testing - Test all prop variations - // -------------------------------------------------------------------------- describe('Props', () => { describe('className prop', () => { it('should apply custom className when provided', () => { - // Arrange & Act const { container } = renderTopBar({ className: 'custom-class' }) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') }) it('should merge custom className with default classes', () => { - // Arrange & Act const { container } = renderTopBar({ className: 'my-custom-class another-class' }) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('relative') expect(wrapper).toHaveClass('flex') @@ -117,20 +98,16 @@ describe('TopBar', () => { }) it('should render correctly without className', () => { - // Arrange & Act const { container } = renderTopBar({ className: undefined }) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('relative') expect(wrapper).toHaveClass('flex') }) it('should handle empty string className', () => { - // Arrange & Act const { container } = renderTopBar({ className: '' }) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('relative') }) @@ -138,34 +115,27 @@ describe('TopBar', () => { describe('datasetId prop', () => { it('should set fallback route to /datasets when datasetId is undefined', () => { - // Arrange & Act renderTopBar({ datasetId: undefined }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', '/datasets') }) it('should set fallback route to /datasets/:id/documents when datasetId is provided', () => { - // Arrange & Act renderTopBar({ datasetId: 'dataset-123' }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', '/datasets/dataset-123/documents') }) it('should handle various datasetId formats', () => { - // Arrange & Act renderTopBar({ datasetId: 'abc-def-ghi-123' }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', '/datasets/abc-def-ghi-123/documents') }) it('should handle empty string datasetId', () => { - // Arrange & Act renderTopBar({ datasetId: '' }) // Assert - Empty string is falsy, so fallback to /datasets @@ -176,7 +146,6 @@ describe('TopBar', () => { describe('activeIndex prop', () => { it('should pass activeIndex to Stepper component (index 0)', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: 0 }) // Assert - First step should be active (has specific styling) @@ -185,7 +154,6 @@ describe('TopBar', () => { }) it('should pass activeIndex to Stepper component (index 1)', () => { - // Arrange & Act renderTopBar({ activeIndex: 1 }) // Assert - Stepper is rendered with correct props @@ -194,15 +162,12 @@ describe('TopBar', () => { }) it('should pass activeIndex to Stepper component (index 2)', () => { - // Arrange & Act renderTopBar({ activeIndex: 2 }) - // Assert expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() }) it('should handle edge case activeIndex of -1', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: -1 }) // Assert - Component should render without crashing @@ -210,7 +175,6 @@ describe('TopBar', () => { }) it('should handle edge case activeIndex beyond steps length', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: 10 }) // Assert - Component should render without crashing @@ -219,15 +183,12 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- // Memoization Tests - Test useMemo logic and dependencies - // -------------------------------------------------------------------------- describe('Memoization Logic', () => { it('should compute fallbackRoute based on datasetId', () => { // Arrange & Act - With datasetId const { rerender } = render() - // Assert expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/test-id/documents') // Act - Rerender with different datasetId @@ -238,35 +199,27 @@ describe('TopBar', () => { }) it('should update fallbackRoute when datasetId changes from undefined to defined', () => { - // Arrange const { rerender } = render() expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') - // Act rerender() - // Assert expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-dataset/documents') }) it('should update fallbackRoute when datasetId changes from defined to undefined', () => { - // Arrange const { rerender } = render() expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/existing-id/documents') - // Act rerender() - // Assert expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') }) it('should not change fallbackRoute when activeIndex changes but datasetId stays same', () => { - // Arrange const { rerender } = render() const initialHref = screen.getByTestId('back-link').getAttribute('href') - // Act rerender() // Assert - href should remain the same @@ -274,11 +227,9 @@ describe('TopBar', () => { }) it('should not change fallbackRoute when className changes but datasetId stays same', () => { - // Arrange const { rerender } = render() const initialHref = screen.getByTestId('back-link').getAttribute('href') - // Act rerender() // Assert - href should remain the same @@ -286,24 +237,18 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- // Link Component Tests - // -------------------------------------------------------------------------- describe('Link Component', () => { it('should render Link with replace prop', () => { - // Arrange & Act renderTopBar() - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('data-replace', 'true') }) it('should render Link with correct classes', () => { - // Arrange & Act renderTopBar() - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveClass('inline-flex') expect(backLink).toHaveClass('h-12') @@ -316,84 +261,63 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- // STEP_T_MAP Tests - Verify step translations - // -------------------------------------------------------------------------- describe('STEP_T_MAP Translations', () => { it('should render step one translation', () => { - // Arrange & Act renderTopBar({ activeIndex: 0 }) - // Assert expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() }) it('should render step two translation', () => { - // Arrange & Act renderTopBar({ activeIndex: 1 }) - // Assert expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() }) it('should render step three translation', () => { - // Arrange & Act renderTopBar({ activeIndex: 2 }) - // Assert expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() }) it('should render all three step translations', () => { - // Arrange & Act renderTopBar({ activeIndex: 0 }) - // Assert expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Edge Cases and Error Handling Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle special characters in datasetId', () => { - // Arrange & Act renderTopBar({ datasetId: 'dataset-with-special_chars.123' }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', '/datasets/dataset-with-special_chars.123/documents') }) it('should handle very long datasetId', () => { - // Arrange const longId = 'a'.repeat(100) - // Act renderTopBar({ datasetId: longId }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', `/datasets/${longId}/documents`) }) it('should handle UUID format datasetId', () => { - // Arrange const uuid = '550e8400-e29b-41d4-a716-446655440000' - // Act renderTopBar({ datasetId: uuid }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', `/datasets/${uuid}/documents`) }) it('should handle whitespace in className', () => { - // Arrange & Act const { container } = renderTopBar({ className: ' spaced-class ' }) // Assert - classNames utility handles whitespace @@ -402,35 +326,28 @@ describe('TopBar', () => { }) it('should render correctly with all props provided', () => { - // Arrange & Act const { container } = renderTopBar({ className: 'custom-class', datasetId: 'full-props-id', activeIndex: 2, }) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/full-props-id/documents') }) it('should render correctly with minimal props (only activeIndex)', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: 0 }) - // Assert expect(container.firstChild).toBeInTheDocument() expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') }) }) - // -------------------------------------------------------------------------- // Stepper Integration Tests - // -------------------------------------------------------------------------- describe('Stepper Integration', () => { it('should pass steps array with correct structure to Stepper', () => { - // Arrange & Act renderTopBar({ activeIndex: 0 }) // Assert - All step names should be rendered @@ -444,7 +361,6 @@ describe('TopBar', () => { }) it('should render Stepper in centered position', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: 0 }) // Assert - Check for centered positioning classes @@ -453,7 +369,6 @@ describe('TopBar', () => { }) it('should render step dividers between steps', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: 0 }) // Assert - Check for dividers (h-px w-4 bg-divider-deep) @@ -462,15 +377,10 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- - // Accessibility Tests - // -------------------------------------------------------------------------- describe('Accessibility', () => { it('should have accessible back link', () => { - // Arrange & Act renderTopBar() - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toBeInTheDocument() // Link should have visible text @@ -478,7 +388,6 @@ describe('TopBar', () => { }) it('should have visible arrow icon in back link', () => { - // Arrange & Act const { container } = renderTopBar() // Assert - Arrow icon should be visible @@ -488,12 +397,9 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- // Re-render Tests - // -------------------------------------------------------------------------- describe('Re-render Behavior', () => { it('should update activeIndex on re-render', () => { - // Arrange const { rerender, container } = render() // Initial check @@ -507,21 +413,17 @@ describe('TopBar', () => { }) it('should update className on re-render', () => { - // Arrange const { rerender, container } = render() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('initial-class') - // Act rerender() - // Assert expect(wrapper).toHaveClass('updated-class') expect(wrapper).not.toHaveClass('initial-class') }) it('should handle multiple rapid re-renders', () => { - // Arrange const { rerender, container } = render() // Act - Multiple rapid re-renders diff --git a/web/app/components/datasets/create/website/base.spec.tsx b/web/app/components/datasets/create/website/__tests__/base.spec.tsx similarity index 94% rename from web/app/components/datasets/create/website/base.spec.tsx rename to web/app/components/datasets/create/website/__tests__/base.spec.tsx index 3843aa780c..980d1b8382 100644 --- a/web/app/components/datasets/create/website/base.spec.tsx +++ b/web/app/components/datasets/create/website/__tests__/base.spec.tsx @@ -1,14 +1,10 @@ import type { CrawlResultItem } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import CrawledResult from './base/crawled-result' -import CrawledResultItem from './base/crawled-result-item' -import Header from './base/header' -import Input from './base/input' - -// ============================================================================ -// Test Data Factories -// ============================================================================ +import CrawledResult from '../base/crawled-result' +import CrawledResultItem from '../base/crawled-result-item' +import Header from '../base/header' +import Input from '../base/input' const createCrawlResultItem = (overrides: Partial = {}): CrawlResultItem => ({ title: 'Test Page Title', @@ -18,9 +14,7 @@ const createCrawlResultItem = (overrides: Partial = {}): CrawlR ...overrides, }) -// ============================================================================ // Input Component Tests -// ============================================================================ describe('Input', () => { beforeEach(() => { @@ -155,9 +149,7 @@ describe('Input', () => { }) }) -// ============================================================================ // Header Component Tests -// ============================================================================ describe('Header', () => { const createHeaderProps = (overrides: Partial[0]> = {}) => ({ @@ -254,9 +246,7 @@ describe('Header', () => { }) }) -// ============================================================================ // CrawledResultItem Component Tests -// ============================================================================ describe('CrawledResultItem', () => { const createItemProps = (overrides: Partial[0]> = {}) => ({ @@ -359,9 +349,7 @@ describe('CrawledResultItem', () => { }) }) -// ============================================================================ // CrawledResult Component Tests -// ============================================================================ describe('CrawledResult', () => { const createResultProps = (overrides: Partial[0]> = {}) => ({ @@ -487,7 +475,6 @@ describe('CrawledResult', () => { const props = createResultProps({ list, checkedList: [list[0], list[1]], onSelectedChange }) render() - // Click the first item's checkbox to uncheck it await userEvent.click(getItemCheckbox(0)) expect(onSelectedChange).toHaveBeenCalledWith([list[1]]) @@ -505,7 +492,6 @@ describe('CrawledResult', () => { render() - // Click preview on second item const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview') await userEvent.click(previewButtons[1]) @@ -522,7 +508,6 @@ describe('CrawledResult', () => { render() - // Click preview on first item const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview') await userEvent.click(previewButtons[0]) diff --git a/web/app/components/datasets/create/website/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f9f6bf6d57 --- /dev/null +++ b/web/app/components/datasets/create/website/__tests__/index.spec.tsx @@ -0,0 +1,286 @@ +import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types' +import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' +import Website from '../index' + +const mockSetShowAccountSettingModal = vi.fn() + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +vi.mock('../index.module.css', () => ({ + default: { + jinaLogo: 'jina-logo', + watercrawlLogo: 'watercrawl-logo', + }, +})) + +vi.mock('../firecrawl', () => ({ + default: (props: Record) =>
, +})) + +vi.mock('../jina-reader', () => ({ + default: (props: Record) =>
, +})) + +vi.mock('../watercrawl', () => ({ + default: (props: Record) =>
, +})) + +vi.mock('../no-data', () => ({ + default: ({ onConfig, provider }: { onConfig: () => void, provider: string }) => ( +
+ +
+ ), +})) + +let mockEnableJinaReader = true +let mockEnableFirecrawl = true +let mockEnableWatercrawl = true + +vi.mock('@/config', () => ({ + get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader }, + get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl }, + get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWatercrawl }, +})) + +const createMockCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ + crawl_sub_pages: true, + limit: 10, + max_depth: 2, + excludes: '', + includes: '', + only_main_content: false, + use_sitemap: false, + ...overrides, +}) + +const createMockDataSourceAuth = ( + provider: string, + credentialsCount = 1, +): DataSourceAuth => ({ + author: 'test', + provider, + plugin_id: `${provider}-plugin`, + plugin_unique_identifier: `${provider}-unique`, + icon: 'icon.png', + name: provider, + label: { en_US: provider, zh_Hans: provider }, + description: { en_US: `${provider} description`, zh_Hans: `${provider} description` }, + credentials_list: Array.from({ length: credentialsCount }, (_, i) => ({ + credential: {}, + type: CredentialTypeEnum.API_KEY, + name: `cred-${i}`, + id: `cred-${i}`, + is_default: i === 0, + avatar_url: '', + })), +}) + +type RenderProps = { + authedDataSourceList?: DataSourceAuth[] + enableJina?: boolean + enableFirecrawl?: boolean + enableWatercrawl?: boolean +} + +const renderWebsite = ({ + authedDataSourceList = [], + enableJina = true, + enableFirecrawl = true, + enableWatercrawl = true, +}: RenderProps = {}) => { + mockEnableJinaReader = enableJina + mockEnableFirecrawl = enableFirecrawl + mockEnableWatercrawl = enableWatercrawl + + const props = { + onPreview: vi.fn() as (payload: CrawlResultItem) => void, + checkedCrawlResult: [] as CrawlResultItem[], + onCheckedCrawlResultChange: vi.fn() as (payload: CrawlResultItem[]) => void, + onCrawlProviderChange: vi.fn(), + onJobIdChange: vi.fn(), + crawlOptions: createMockCrawlOptions(), + onCrawlOptionsChange: vi.fn() as (payload: CrawlOptions) => void, + authedDataSourceList, + } + + const result = render() + return { ...result, props } +} + +describe('Website', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnableJinaReader = true + mockEnableFirecrawl = true + mockEnableWatercrawl = true + }) + + describe('Rendering', () => { + it('should render provider selection section', () => { + renderWebsite() + expect(screen.getByText(/chooseProvider/i)).toBeInTheDocument() + }) + + it('should show Jina Reader button when ENABLE_WEBSITE_JINAREADER is true', () => { + renderWebsite({ enableJina: true }) + expect(screen.getByText('Jina Reader')).toBeInTheDocument() + }) + + it('should not show Jina Reader button when ENABLE_WEBSITE_JINAREADER is false', () => { + renderWebsite({ enableJina: false }) + expect(screen.queryByText('Jina Reader')).not.toBeInTheDocument() + }) + + it('should show Firecrawl button when ENABLE_WEBSITE_FIRECRAWL is true', () => { + renderWebsite({ enableFirecrawl: true }) + expect(screen.getByText(/Firecrawl/)).toBeInTheDocument() + }) + + it('should not show Firecrawl button when ENABLE_WEBSITE_FIRECRAWL is false', () => { + renderWebsite({ enableFirecrawl: false }) + expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument() + }) + + it('should show WaterCrawl button when ENABLE_WEBSITE_WATERCRAWL is true', () => { + renderWebsite({ enableWatercrawl: true }) + expect(screen.getByText('WaterCrawl')).toBeInTheDocument() + }) + + it('should not show WaterCrawl button when ENABLE_WEBSITE_WATERCRAWL is false', () => { + renderWebsite({ enableWatercrawl: false }) + expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument() + }) + }) + + describe('Provider Selection', () => { + it('should select Jina Reader by default', () => { + const authedDataSourceList = [createMockDataSourceAuth('jinareader')] + renderWebsite({ authedDataSourceList }) + + expect(screen.getByTestId('jina-reader-component')).toBeInTheDocument() + }) + + it('should switch to Firecrawl when Firecrawl button clicked', () => { + const authedDataSourceList = [ + createMockDataSourceAuth('jinareader'), + createMockDataSourceAuth('firecrawl'), + ] + renderWebsite({ authedDataSourceList }) + + const firecrawlButton = screen.getByText(/Firecrawl/) + fireEvent.click(firecrawlButton) + + expect(screen.getByTestId('firecrawl-component')).toBeInTheDocument() + expect(screen.queryByTestId('jina-reader-component')).not.toBeInTheDocument() + }) + + it('should switch to WaterCrawl when WaterCrawl button clicked', () => { + const authedDataSourceList = [ + createMockDataSourceAuth('jinareader'), + createMockDataSourceAuth('watercrawl'), + ] + renderWebsite({ authedDataSourceList }) + + const watercrawlButton = screen.getByText('WaterCrawl') + fireEvent.click(watercrawlButton) + + expect(screen.getByTestId('watercrawl-component')).toBeInTheDocument() + expect(screen.queryByTestId('jina-reader-component')).not.toBeInTheDocument() + }) + + it('should call onCrawlProviderChange when provider switched', () => { + const authedDataSourceList = [ + createMockDataSourceAuth('jinareader'), + createMockDataSourceAuth('firecrawl'), + ] + const { props } = renderWebsite({ authedDataSourceList }) + + const firecrawlButton = screen.getByText(/Firecrawl/) + fireEvent.click(firecrawlButton) + + expect(props.onCrawlProviderChange).toHaveBeenCalledWith('firecrawl') + }) + }) + + describe('Provider Content', () => { + it('should show JinaReader component when selected and available', () => { + const authedDataSourceList = [createMockDataSourceAuth('jinareader')] + renderWebsite({ authedDataSourceList }) + + expect(screen.getByTestId('jina-reader-component')).toBeInTheDocument() + }) + + it('should show Firecrawl component when selected and available', () => { + const authedDataSourceList = [ + createMockDataSourceAuth('jinareader'), + createMockDataSourceAuth('firecrawl'), + ] + renderWebsite({ authedDataSourceList }) + + const firecrawlButton = screen.getByText(/Firecrawl/) + fireEvent.click(firecrawlButton) + + expect(screen.getByTestId('firecrawl-component')).toBeInTheDocument() + }) + + it('should show NoData when selected provider has no credentials', () => { + const authedDataSourceList = [createMockDataSourceAuth('jinareader', 0)] + renderWebsite({ authedDataSourceList }) + + expect(screen.getByTestId('no-data-component')).toBeInTheDocument() + }) + + it('should show NoData when no data source available for selected provider', () => { + renderWebsite({ authedDataSourceList: [] }) + + expect(screen.getByTestId('no-data-component')).toBeInTheDocument() + }) + }) + + describe('NoData Config', () => { + it('should call setShowAccountSettingModal when NoData onConfig is triggered', () => { + renderWebsite({ authedDataSourceList: [] }) + + const configButton = screen.getByTestId('no-data-config-button') + fireEvent.click(configButton) + + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: 'data-source', + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle no providers enabled', () => { + renderWebsite({ + enableJina: false, + enableFirecrawl: false, + enableWatercrawl: false, + }) + + expect(screen.queryByText('Jina Reader')).not.toBeInTheDocument() + expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument() + expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument() + }) + + it('should handle only one provider enabled', () => { + renderWebsite({ + enableJina: true, + enableFirecrawl: false, + enableWatercrawl: false, + }) + + expect(screen.getByText('Jina Reader')).toBeInTheDocument() + expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument() + expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/website/__tests__/no-data.spec.tsx b/web/app/components/datasets/create/website/__tests__/no-data.spec.tsx new file mode 100644 index 0000000000..b19e117d69 --- /dev/null +++ b/web/app/components/datasets/create/website/__tests__/no-data.spec.tsx @@ -0,0 +1,185 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceProvider } from '@/models/common' +import NoData from '../no-data' + +// Mock Setup + +// Mock CSS module +vi.mock('../index.module.css', () => ({ + default: { + jinaLogo: 'jinaLogo', + watercrawlLogo: 'watercrawlLogo', + }, +})) + +// Feature flags - default all enabled +let mockEnableFirecrawl = true +let mockEnableJinaReader = true +let mockEnableWaterCrawl = true + +vi.mock('@/config', () => ({ + get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl }, + get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader }, + get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWaterCrawl }, +})) + +// NoData Component Tests + +describe('NoData', () => { + const mockOnConfig = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockEnableFirecrawl = true + mockEnableJinaReader = true + mockEnableWaterCrawl = true + }) + + // Rendering Tests - Per Provider + describe('Rendering per provider', () => { + it('should render fireCrawl provider with emoji and not-configured message', () => { + render() + + expect(screen.getByText('đŸ”„')).toBeInTheDocument() + const titleAndDesc = screen.getAllByText(/fireCrawlNotConfigured/i) + expect(titleAndDesc).toHaveLength(2) + }) + + it('should render jinaReader provider with jina logo and not-configured message', () => { + render() + + const titleAndDesc = screen.getAllByText(/jinaReaderNotConfigured/i) + expect(titleAndDesc).toHaveLength(2) + }) + + it('should render waterCrawl provider with emoji and not-configured message', () => { + render() + + expect(screen.getByText('💧')).toBeInTheDocument() + const titleAndDesc = screen.getAllByText(/waterCrawlNotConfigured/i) + expect(titleAndDesc).toHaveLength(2) + }) + + it('should render configure button for each provider', () => { + render() + + expect(screen.getByRole('button', { name: /configure/i })).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onConfig when configure button is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /configure/i })) + + expect(mockOnConfig).toHaveBeenCalledTimes(1) + }) + + it('should call onConfig for jinaReader provider', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /configure/i })) + + expect(mockOnConfig).toHaveBeenCalledTimes(1) + }) + + it('should call onConfig for waterCrawl provider', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /configure/i })) + + expect(mockOnConfig).toHaveBeenCalledTimes(1) + }) + }) + + // Feature Flag Disabled - Returns null + describe('Disabled providers (feature flag off)', () => { + it('should fall back to jinaReader when fireCrawl is disabled but jinaReader enabled', () => { + // Arrange — fireCrawl config is null, falls back to providerConfig.jinareader + mockEnableFirecrawl = false + + const { container } = render( + , + ) + + // Assert — renders the jinaReader fallback (not null) + expect(container.innerHTML).not.toBe('') + expect(screen.getAllByText(/jinaReaderNotConfigured/).length).toBeGreaterThan(0) + }) + + it('should return null when jinaReader is disabled', () => { + // Arrange — jinaReader is the only provider without a fallback + mockEnableJinaReader = false + + const { container } = render( + , + ) + + expect(container.innerHTML).toBe('') + }) + + it('should fall back to jinaReader when waterCrawl is disabled but jinaReader enabled', () => { + // Arrange — waterCrawl config is null, falls back to providerConfig.jinareader + mockEnableWaterCrawl = false + + const { container } = render( + , + ) + + // Assert — renders the jinaReader fallback (not null) + expect(container.innerHTML).not.toBe('') + expect(screen.getAllByText(/jinaReaderNotConfigured/).length).toBeGreaterThan(0) + }) + }) + + // Fallback behavior + describe('Fallback behavior', () => { + it('should fall back to jinaReader config for unknown provider value', () => { + // Arrange - the || fallback goes to providerConfig.jinareader + // Since DataSourceProvider only has 3 values, we test the fallback + // by checking that jinaReader is the fallback when provider doesn't match + mockEnableJinaReader = true + + render() + + expect(screen.getAllByText(/jinaReaderNotConfigured/i).length).toBeGreaterThan(0) + }) + }) + + describe('Edge Cases', () => { + it('should not call onConfig without user interaction', () => { + render() + + expect(mockOnConfig).not.toHaveBeenCalled() + }) + + it('should render correctly when all providers are enabled', () => { + // Arrange - all flags are true by default + + const { rerender } = render( + , + ) + expect(screen.getByText('đŸ”„')).toBeInTheDocument() + + rerender() + expect(screen.getAllByText(/jinaReaderNotConfigured/i).length).toBeGreaterThan(0) + + rerender() + expect(screen.getByText('💧')).toBeInTheDocument() + }) + + it('should return null when all providers are disabled and fireCrawl is selected', () => { + mockEnableFirecrawl = false + mockEnableJinaReader = false + mockEnableWaterCrawl = false + + const { container } = render( + , + ) + + expect(container.innerHTML).toBe('') + }) + }) +}) diff --git a/web/app/components/datasets/create/website/__tests__/preview.spec.tsx b/web/app/components/datasets/create/website/__tests__/preview.spec.tsx new file mode 100644 index 0000000000..9fe447c95c --- /dev/null +++ b/web/app/components/datasets/create/website/__tests__/preview.spec.tsx @@ -0,0 +1,197 @@ +import type { CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WebsitePreview from '../preview' + +// Mock Setup + +// Mock the CSS module import - returns class names as-is +vi.mock('../../file-preview/index.module.css', () => ({ + default: { + filePreview: 'filePreview', + previewHeader: 'previewHeader', + title: 'title', + previewContent: 'previewContent', + fileContent: 'fileContent', + }, +})) + +// Test Data Factory + +const createPayload = (overrides: Partial = {}): CrawlResultItem => ({ + title: 'Test Page Title', + markdown: 'This is **markdown** content', + description: 'A test description', + source_url: 'https://example.com/page', + ...overrides, +}) + +// WebsitePreview Component Tests + +describe('WebsitePreview', () => { + const mockHidePreview = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const payload = createPayload() + + render() + + expect(screen.getByText('Test Page Title')).toBeInTheDocument() + }) + + it('should render the page preview header text', () => { + const payload = createPayload() + + render() + + // Assert - i18n returns the key path + expect(screen.getByText(/pagePreview/i)).toBeInTheDocument() + }) + + it('should render the payload title', () => { + const payload = createPayload({ title: 'My Custom Page' }) + + render() + + expect(screen.getByText('My Custom Page')).toBeInTheDocument() + }) + + it('should render the payload source_url', () => { + const payload = createPayload({ source_url: 'https://docs.dify.ai/intro' }) + + render() + + const urlElement = screen.getByText('https://docs.dify.ai/intro') + expect(urlElement).toBeInTheDocument() + expect(urlElement).toHaveAttribute('title', 'https://docs.dify.ai/intro') + }) + + it('should render the payload markdown content', () => { + const payload = createPayload({ markdown: 'Hello world markdown' }) + + render() + + expect(screen.getByText('Hello world markdown')).toBeInTheDocument() + }) + + it('should render the close button (XMarkIcon)', () => { + const payload = createPayload() + + render() + + // Assert - the close button container is a div with cursor-pointer + const closeButton = screen.getByText(/pagePreview/i).parentElement?.querySelector('.cursor-pointer') + expect(closeButton).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call hidePreview when close button is clicked', () => { + const payload = createPayload() + render() + + // Act - find the close button div with cursor-pointer class + const closeButton = screen.getByText(/pagePreview/i) + .closest('[class*="title"]')! + .querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + + expect(mockHidePreview).toHaveBeenCalledTimes(1) + }) + + it('should call hidePreview exactly once per click', () => { + const payload = createPayload() + render() + + const closeButton = screen.getByText(/pagePreview/i) + .closest('[class*="title"]')! + .querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + fireEvent.click(closeButton) + + expect(mockHidePreview).toHaveBeenCalledTimes(2) + }) + }) + + // Props Display Tests + describe('Props Display', () => { + it('should display all payload fields simultaneously', () => { + const payload = createPayload({ + title: 'Full Title', + source_url: 'https://full.example.com', + markdown: 'Full markdown text', + }) + + render() + + expect(screen.getByText('Full Title')).toBeInTheDocument() + expect(screen.getByText('https://full.example.com')).toBeInTheDocument() + expect(screen.getByText('Full markdown text')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with empty title', () => { + const payload = createPayload({ title: '' }) + + render() + + // Assert - component still renders, url is visible + expect(screen.getByText('https://example.com/page')).toBeInTheDocument() + }) + + it('should render with empty markdown', () => { + const payload = createPayload({ markdown: '' }) + + render() + + expect(screen.getByText('Test Page Title')).toBeInTheDocument() + }) + + it('should render with empty source_url', () => { + const payload = createPayload({ source_url: '' }) + + render() + + expect(screen.getByText('Test Page Title')).toBeInTheDocument() + }) + + it('should render with very long content', () => { + const longMarkdown = 'A'.repeat(5000) + const payload = createPayload({ markdown: longMarkdown }) + + render() + + expect(screen.getByText(longMarkdown)).toBeInTheDocument() + }) + + it('should render with special characters in title', () => { + const payload = createPayload({ title: '' }) + + render() + + // Assert - React escapes HTML by default + expect(screen.getByText('')).toBeInTheDocument() + }) + }) + + // CSS Module Classes + describe('CSS Module Classes', () => { + it('should apply filePreview class to root container', () => { + const payload = createPayload() + + const { container } = render( + , + ) + + const root = container.firstElementChild + expect(root?.className).toContain('filePreview') + expect(root?.className).toContain('h-full') + }) + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/checkbox-with-label.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/checkbox-with-label.spec.tsx new file mode 100644 index 0000000000..a3c246054d --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/checkbox-with-label.spec.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CheckboxWithLabel from '../checkbox-with-label' + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ popupContent }: { popupContent?: React.ReactNode }) =>
{popupContent}
, +})) + +describe('CheckboxWithLabel', () => { + const onChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render label', () => { + render() + expect(screen.getByText('Accept terms')).toBeInTheDocument() + }) + + it('should render tooltip when provided', () => { + render( + , + ) + expect(screen.getByTestId('tooltip')).toBeInTheDocument() + }) + + it('should not render tooltip when not provided', () => { + render() + expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + }) + + it('should toggle checked state on checkbox click', () => { + render() + fireEvent.click(screen.getByTestId('checkbox-my-check')) + expect(onChange).toHaveBeenCalledWith(true) + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx new file mode 100644 index 0000000000..5087bfdbda --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx @@ -0,0 +1,43 @@ +import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CrawledResultItem from '../crawled-result-item' + +describe('CrawledResultItem', () => { + const defaultProps = { + payload: { title: 'Example Page', source_url: 'https://example.com/page' } as CrawlResultItemType, + isChecked: false, + isPreview: false, + onCheckChange: vi.fn(), + onPreview: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render title and url', () => { + render() + expect(screen.getByText('Example Page')).toBeInTheDocument() + expect(screen.getByText('https://example.com/page')).toBeInTheDocument() + }) + + it('should apply active styling when isPreview', () => { + const { container } = render() + expect((container.firstChild as HTMLElement).className).toContain('bg-state-base-active') + }) + + it('should call onCheckChange with true when unchecked checkbox is clicked', () => { + render() + const checkbox = screen.getByTestId('checkbox-crawl-item') + fireEvent.click(checkbox) + expect(defaultProps.onCheckChange).toHaveBeenCalledWith(true) + }) + + it('should call onCheckChange with false when checked checkbox is clicked', () => { + render() + const checkbox = screen.getByTestId('checkbox-crawl-item') + fireEvent.click(checkbox) + expect(defaultProps.onCheckChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/crawled-result.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/crawled-result.spec.tsx new file mode 100644 index 0000000000..922ae2adc9 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/crawled-result.spec.tsx @@ -0,0 +1,313 @@ +import type { CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CrawledResult from '../crawled-result' + +vi.mock('../checkbox-with-label', () => ({ + default: ({ isChecked, onChange, label, testId }: { + isChecked: boolean + onChange: (checked: boolean) => void + label: string + testId?: string + }) => ( + + ), +})) + +vi.mock('../crawled-result-item', () => ({ + default: ({ payload, isChecked, isPreview, onCheckChange, onPreview, testId }: { + payload: CrawlResultItem + isChecked: boolean + isPreview: boolean + onCheckChange: (checked: boolean) => void + onPreview: () => void + testId?: string + }) => ( +
+ onCheckChange(!isChecked)} + data-testid={`check-${testId}`} + /> + {payload.title} + {payload.source_url} + +
+ ), +})) + +const createMockItem = (overrides: Partial = {}): CrawlResultItem => ({ + title: 'Test Page', + markdown: '# Test', + description: 'A test page', + source_url: 'https://example.com', + ...overrides, +}) + +const createMockList = (): CrawlResultItem[] => [ + createMockItem({ title: 'Page 1', source_url: 'https://example.com/1' }), + createMockItem({ title: 'Page 2', source_url: 'https://example.com/2' }), + createMockItem({ title: 'Page 3', source_url: 'https://example.com/3' }), +] + +describe('CrawledResult', () => { + const mockOnSelectedChange = vi.fn() + const mockOnPreview = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render select all checkbox', () => { + const list = createMockList() + render( + , + ) + + expect(screen.getByTestId('select-all')).toBeInTheDocument() + }) + + it('should render all items from list', () => { + const list = createMockList() + render( + , + ) + + expect(screen.getByTestId('item-0')).toBeInTheDocument() + expect(screen.getByTestId('item-1')).toBeInTheDocument() + expect(screen.getByTestId('item-2')).toBeInTheDocument() + }) + + it('should render scrap time info', () => { + const list = createMockList() + render( + , + ) + + expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument() + }) + + it('should apply custom className', () => { + const list = createMockList() + const { container } = render( + , + ) + + const rootElement = container.firstChild as HTMLElement + expect(rootElement).toHaveClass('custom-class') + }) + }) + + describe('Select All', () => { + it('should call onSelectedChange with full list when not all checked', () => { + const list = createMockList() + render( + , + ) + + const selectAllCheckbox = screen.getByTestId('checkbox-select-all') + fireEvent.click(selectAllCheckbox) + + expect(mockOnSelectedChange).toHaveBeenCalledWith(list) + }) + + it('should call onSelectedChange with empty array when all checked', () => { + const list = createMockList() + render( + , + ) + + const selectAllCheckbox = screen.getByTestId('checkbox-select-all') + fireEvent.click(selectAllCheckbox) + + expect(mockOnSelectedChange).toHaveBeenCalledWith([]) + }) + + it('should show selectAll label when not all checked', () => { + const list = createMockList() + render( + , + ) + + expect(screen.getByText(/selectAll/i)).toBeInTheDocument() + }) + + it('should show resetAll label when all checked', () => { + const list = createMockList() + render( + , + ) + + expect(screen.getByText(/resetAll/i)).toBeInTheDocument() + }) + }) + + describe('Individual Item Check', () => { + it('should call onSelectedChange with added item when checking', () => { + const list = createMockList() + const checkedList = [list[0]] + render( + , + ) + + const item1Checkbox = screen.getByTestId('check-item-1') + fireEvent.click(item1Checkbox) + + expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0], list[1]]) + }) + + it('should call onSelectedChange with removed item when unchecking', () => { + const list = createMockList() + const checkedList = [list[0], list[1]] + render( + , + ) + + const item0Checkbox = screen.getByTestId('check-item-0') + fireEvent.click(item0Checkbox) + + expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]]) + }) + }) + + describe('Preview', () => { + it('should call onPreview with correct item when preview clicked', () => { + const list = createMockList() + render( + , + ) + + const previewButton = screen.getByTestId('preview-item-1') + fireEvent.click(previewButton) + + expect(mockOnPreview).toHaveBeenCalledWith(list[1]) + }) + + it('should update preview state when preview button is clicked', () => { + const list = createMockList() + render( + , + ) + + const previewButton = screen.getByTestId('preview-item-0') + fireEvent.click(previewButton) + + const item0 = screen.getByTestId('item-0') + expect(item0).toHaveAttribute('data-preview', 'true') + }) + }) + + describe('Edge Cases', () => { + it('should render empty list without crashing', () => { + render( + , + ) + + expect(screen.getByTestId('select-all')).toBeInTheDocument() + }) + + it('should handle single item list', () => { + const list = [createMockItem()] + render( + , + ) + + expect(screen.getByTestId('item-0')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx new file mode 100644 index 0000000000..36fbf6fbc5 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Crawling from '../crawling' + +vi.mock('@/app/components/base/icons/src/public/other', () => ({ + RowStruct: (props: React.HTMLAttributes) =>
, +})) + +describe('Crawling', () => { + it('should render crawled count and total', () => { + render() + expect(screen.getByText(/3/)).toBeInTheDocument() + expect(screen.getByText(/10/)).toBeInTheDocument() + }) + + it('should render skeleton rows', () => { + render() + expect(screen.getAllByTestId('row-struct')).toHaveLength(4) + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx new file mode 100644 index 0000000000..c521822982 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ErrorMessage from '../error-message' + +vi.mock('@/app/components/base/icons/src/vender/solid/alertsAndFeedback', () => ({ + AlertTriangle: (props: React.SVGProps) => , +})) + +describe('ErrorMessage', () => { + it('should render title', () => { + render() + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('should render error message when provided', () => { + render() + expect(screen.getByText('Detailed error info')).toBeInTheDocument() + }) + + it('should not render error message when not provided', () => { + render() + expect(screen.queryByText('Detailed error info')).not.toBeInTheDocument() + }) + + it('should render alert icon', () => { + render() + expect(screen.getByTestId('alert-icon')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx new file mode 100644 index 0000000000..8a2e147d60 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Field from '../field' + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ popupContent }: { popupContent?: React.ReactNode }) =>
{popupContent}
, +})) + +describe('WebsiteField', () => { + const onChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render label', () => { + render() + expect(screen.getByText('URL')).toBeInTheDocument() + }) + + it('should render required asterisk when isRequired', () => { + render() + expect(screen.getByText('*')).toBeInTheDocument() + }) + + it('should not render required asterisk by default', () => { + render() + expect(screen.queryByText('*')).not.toBeInTheDocument() + }) + + it('should render tooltip when provided', () => { + render() + expect(screen.getByTestId('tooltip')).toBeInTheDocument() + }) + + it('should pass value and onChange to Input', () => { + render() + expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument() + }) + + it('should call onChange when input changes', () => { + render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } }) + expect(onChange).toHaveBeenCalledWith('new') + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/header.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/header.spec.tsx new file mode 100644 index 0000000000..8564242439 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/header.spec.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Header from '../header' + +describe('WebsiteHeader', () => { + const defaultProps = { + title: 'Jina Reader', + docTitle: 'Documentation', + docLink: 'https://docs.example.com', + onClickConfiguration: vi.fn(), + buttonText: 'Config', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render title', () => { + render(
) + expect(screen.getByText('Jina Reader')).toBeInTheDocument() + }) + + it('should render doc link with correct href', () => { + render(
) + const link = screen.getByText('Documentation').closest('a') + expect(link).toHaveAttribute('href', 'https://docs.example.com') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('should render configuration button with text when not in pipeline', () => { + render(
) + expect(screen.getByText('Config')).toBeInTheDocument() + }) + + it('should call onClickConfiguration on button click', () => { + render(
) + fireEvent.click(screen.getByText('Config').closest('button')!) + expect(defaultProps.onClickConfiguration).toHaveBeenCalledOnce() + }) + + it('should hide button text when isInPipeline', () => { + render(
) + expect(screen.queryByText('Config')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/input.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/input.spec.tsx new file mode 100644 index 0000000000..c8d5301156 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/input.spec.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Input from '../input' + +describe('WebsiteInput', () => { + const onChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render text input by default', () => { + render() + const input = screen.getByDisplayValue('hello') + expect(input).toHaveAttribute('type', 'text') + }) + + it('should render number input when isNumber is true', () => { + render() + const input = screen.getByDisplayValue('42') + expect(input).toHaveAttribute('type', 'number') + }) + + it('should call onChange with string value for text input', () => { + render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new value' } }) + expect(onChange).toHaveBeenCalledWith('new value') + }) + + it('should call onChange with parsed integer for number input', () => { + render() + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '10' } }) + expect(onChange).toHaveBeenCalledWith(10) + }) + + it('should call onChange with empty string for NaN number input', () => { + render() + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } }) + expect(onChange).toHaveBeenCalledWith('') + }) + + it('should clamp negative numbers to 0', () => { + render() + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '-5' } }) + expect(onChange).toHaveBeenCalledWith(0) + }) + + it('should render placeholder', () => { + render() + expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx new file mode 100644 index 0000000000..06e62d41fb --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import OptionsWrap from '../options-wrap' + +vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ + ChevronRight: (props: React.SVGProps) => , +})) + +describe('OptionsWrap', () => { + it('should render children when not folded', () => { + render( + +
Options here
+
, + ) + expect(screen.getByTestId('child-content')).toBeInTheDocument() + }) + + it('should toggle fold on click', () => { + render( + +
Options here
+
, + ) + // Initially visible + expect(screen.getByTestId('child-content')).toBeInTheDocument() + + fireEvent.click(screen.getByText('datasetCreation.stepOne.website.options')) + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('datasetCreation.stepOne.website.options')) + expect(screen.getByTestId('child-content')).toBeInTheDocument() + }) + + it('should render options label', () => { + render( + +
Content
+
, + ) + expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/website/base/url-input.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx similarity index 86% rename from web/app/components/datasets/create/website/base/url-input.spec.tsx rename to web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx index 30d6ffcb93..5301d55307 100644 --- a/web/app/components/datasets/create/website/base/url-input.spec.tsx +++ b/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx @@ -2,24 +2,18 @@ import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -// ============================================================================ // Component Imports (after mocks) -// ============================================================================ -import UrlInput from './url-input' +import UrlInput from '../url-input' -// ============================================================================ // Mock Setup -// ============================================================================ // Mock useDocLink hook vi.mock('@/context/i18n', () => ({ useDocLink: vi.fn(() => () => 'https://docs.example.com'), })) -// ============================================================================ // UrlInput Component Tests -// ============================================================================ describe('UrlInput', () => { const mockOnRun = vi.fn() @@ -28,9 +22,6 @@ describe('UrlInput', () => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render() @@ -71,9 +62,6 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should update input value when user types', async () => { const user = userEvent.setup() @@ -146,9 +134,7 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // Props Variations Tests - // -------------------------------------------------------------------------- describe('Props Variations', () => { it('should update button state when isRunning changes from false to true', () => { const { rerender } = render() @@ -190,9 +176,6 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle special characters in url', async () => { const user = userEvent.setup() @@ -272,9 +255,7 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // handleOnRun Branch Coverage Tests - // -------------------------------------------------------------------------- describe('handleOnRun Branch Coverage', () => { it('should return early when isRunning is true (branch: isRunning = true)', async () => { const user = userEvent.setup() @@ -307,9 +288,7 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // Button Text Branch Coverage Tests - // -------------------------------------------------------------------------- describe('Button Text Branch Coverage', () => { it('should display run text when isRunning is false (branch: !isRunning = true)', () => { render() @@ -328,9 +307,6 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render() @@ -368,9 +344,6 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- - // Integration Tests - // -------------------------------------------------------------------------- describe('Integration', () => { it('should complete full workflow: type url -> click run -> verify callback', async () => { const user = userEvent.setup() @@ -381,7 +354,6 @@ describe('UrlInput', () => { const input = screen.getByRole('textbox') await user.type(input, 'https://mywebsite.com') - // Click run const button = screen.getByRole('button') await user.click(button) diff --git a/web/app/components/datasets/create/website/firecrawl/index.spec.tsx b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/create/website/firecrawl/index.spec.tsx rename to web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx index b39fb2aab1..7df3881824 100644 --- a/web/app/components/datasets/create/website/firecrawl/index.spec.tsx +++ b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx @@ -3,15 +3,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -// ============================================================================ // Component Import (after mocks) -// ============================================================================ -import FireCrawl from './index' +import FireCrawl from '../index' -// ============================================================================ // Mock Setup - Only mock API calls and context -// ============================================================================ // Mock API service const mockCreateFirecrawlTask = vi.fn() @@ -38,9 +34,7 @@ vi.mock('@/context/i18n', () => ({ useDocLink: vi.fn(() => () => 'https://docs.example.com'), })) -// ============================================================================ // Test Data Factory -// ============================================================================ const createMockCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ crawl_sub_pages: true, @@ -61,9 +55,7 @@ const createMockCrawlResultItem = (overrides: Partial = {}): Cr ...overrides, }) -// ============================================================================ // FireCrawl Component Tests -// ============================================================================ describe('FireCrawl', () => { const mockOnPreview = vi.fn() @@ -91,9 +83,6 @@ describe('FireCrawl', () => { return screen.getByPlaceholderText('https://docs.example.com') } - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render() @@ -131,9 +120,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Configuration Button Tests - // -------------------------------------------------------------------------- describe('Configuration Button', () => { it('should call setShowAccountSettingModal when configure button is clicked', async () => { const user = userEvent.setup() @@ -148,9 +135,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // URL Validation Tests - // -------------------------------------------------------------------------- describe('URL Validation', () => { it('should show error toast when URL is empty', async () => { const user = userEvent.setup() @@ -261,9 +246,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Crawl Execution Tests - // -------------------------------------------------------------------------- describe('Crawl Execution', () => { it('should call createFirecrawlTask with correct parameters', async () => { const user = userEvent.setup() @@ -372,9 +355,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Crawl Status Polling Tests - // -------------------------------------------------------------------------- describe('Crawl Status Polling', () => { it('should handle completed status', async () => { const user = userEvent.setup() @@ -508,9 +489,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Error Handling Tests - // -------------------------------------------------------------------------- describe('Error Handling', () => { it('should handle API exception during task creation', async () => { const user = userEvent.setup() @@ -594,9 +573,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Options Change Tests - // -------------------------------------------------------------------------- describe('Options Change', () => { it('should call onCrawlOptionsChange when options change', () => { render() @@ -623,9 +600,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Crawled Result Display Tests - // -------------------------------------------------------------------------- describe('Crawled Result Display', () => { it('should display CrawledResult when crawl is finished successfully', async () => { const user = userEvent.setup() @@ -686,9 +661,6 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render() diff --git a/web/app/components/datasets/create/website/firecrawl/options.spec.tsx b/web/app/components/datasets/create/website/firecrawl/__tests__/options.spec.tsx similarity index 90% rename from web/app/components/datasets/create/website/firecrawl/options.spec.tsx rename to web/app/components/datasets/create/website/firecrawl/__tests__/options.spec.tsx index bd050ce34a..ee5b5d43e6 100644 --- a/web/app/components/datasets/create/website/firecrawl/options.spec.tsx +++ b/web/app/components/datasets/create/website/firecrawl/__tests__/options.spec.tsx @@ -1,11 +1,9 @@ import type { CrawlOptions } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Options from './options' +import Options from '../options' -// ============================================================================ // Test Data Factory -// ============================================================================ const createMockCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ crawl_sub_pages: true, @@ -18,9 +16,7 @@ const createMockCrawlOptions = (overrides: Partial = {}): CrawlOpt ...overrides, }) -// ============================================================================ // Options Component Tests -// ============================================================================ describe('Options', () => { const mockOnChange = vi.fn() @@ -34,9 +30,6 @@ describe('Options', () => { return container.querySelectorAll('[data-testid^="checkbox-"]') } - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { const payload = createMockCrawlOptions() @@ -107,9 +100,7 @@ describe('Options', () => { }) }) - // -------------------------------------------------------------------------- // Props Display Tests - // -------------------------------------------------------------------------- describe('Props Display', () => { it('should display crawl_sub_pages checkbox with check icon when true', () => { const payload = createMockCrawlOptions({ crawl_sub_pages: true }) @@ -180,9 +171,6 @@ describe('Options', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => { const payload = createMockCrawlOptions({ crawl_sub_pages: true }) @@ -263,9 +251,6 @@ describe('Options', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty string values', () => { const payload = createMockCrawlOptions({ @@ -340,9 +325,7 @@ describe('Options', () => { }) }) - // -------------------------------------------------------------------------- // handleChange Callback Tests - // -------------------------------------------------------------------------- describe('handleChange Callback', () => { it('should create a new callback for each key', () => { const payload = createMockCrawlOptions() @@ -378,9 +361,6 @@ describe('Options', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { const payload = createMockCrawlOptions() diff --git a/web/app/components/datasets/create/website/jina-reader/base.spec.tsx b/web/app/components/datasets/create/website/jina-reader/__tests__/base.spec.tsx similarity index 82% rename from web/app/components/datasets/create/website/jina-reader/base.spec.tsx rename to web/app/components/datasets/create/website/jina-reader/__tests__/base.spec.tsx index 7bed7dcf45..bcfcf39060 100644 --- a/web/app/components/datasets/create/website/jina-reader/base.spec.tsx +++ b/web/app/components/datasets/create/website/jina-reader/__tests__/base.spec.tsx @@ -1,15 +1,13 @@ import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import UrlInput from './base/url-input' +import UrlInput from '../base/url-input' // Mock doc link context vi.mock('@/context/i18n', () => ({ useDocLink: () => () => 'https://docs.example.com', })) -// ============================================================================ // UrlInput Component Tests -// ============================================================================ describe('UrlInput', () => { beforeEach(() => { @@ -23,50 +21,36 @@ describe('UrlInput', () => { ...overrides, }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createUrlInputProps() - // Act render() - // Assert expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() }) it('should render input with placeholder from docLink', () => { - // Arrange const props = createUrlInputProps() - // Act render() - // Assert const input = screen.getByRole('textbox') expect(input).toHaveAttribute('placeholder', 'https://docs.example.com') }) it('should render run button with correct text when not running', () => { - // Arrange const props = createUrlInputProps({ isRunning: false }) - // Act render() - // Assert expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() }) it('should render button without text when running', () => { - // Arrange const props = createUrlInputProps({ isRunning: true }) - // Act render() // Assert - find button by data-testid when in loading state @@ -77,11 +61,9 @@ describe('UrlInput', () => { }) it('should show loading state on button when running', () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ isRunning: true, onRun }) - // Act render() // Assert - find button by data-testid when in loading state @@ -97,100 +79,77 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // User Input Tests - // -------------------------------------------------------------------------- describe('User Input', () => { it('should update URL value when user types', async () => { - // Arrange const props = createUrlInputProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://test.com') - // Assert expect(input).toHaveValue('https://test.com') }) it('should handle URL input clearing', async () => { - // Arrange const props = createUrlInputProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://test.com') await userEvent.clear(input) - // Assert expect(input).toHaveValue('') }) it('should handle special characters in URL', async () => { - // Arrange const props = createUrlInputProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com/path?query=value&foo=bar') - // Assert expect(input).toHaveValue('https://example.com/path?query=value&foo=bar') }) }) - // -------------------------------------------------------------------------- // Button Click Tests - // -------------------------------------------------------------------------- describe('Button Click', () => { it('should call onRun with URL when button is clicked', async () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ onRun }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://run-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert expect(onRun).toHaveBeenCalledWith('https://run-test.com') expect(onRun).toHaveBeenCalledTimes(1) }) it('should call onRun with empty string if no URL entered', async () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ onRun }) - // Act render() await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert expect(onRun).toHaveBeenCalledWith('') }) it('should not call onRun when isRunning is true', async () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ onRun, isRunning: true }) - // Act render() const runButton = screen.getByTestId('url-input-run-button') fireEvent.click(runButton) - // Assert expect(onRun).not.toHaveBeenCalled() }) it('should not call onRun when already running', async () => { - // Arrange const onRun = vi.fn() // First render with isRunning=false, type URL, then rerender with isRunning=true @@ -210,31 +169,24 @@ describe('UrlInput', () => { }) it('should prevent multiple clicks when already running', async () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ onRun, isRunning: true }) - // Act render() const runButton = screen.getByTestId('url-input-run-button') fireEvent.click(runButton) fireEvent.click(runButton) fireEvent.click(runButton) - // Assert expect(onRun).not.toHaveBeenCalled() }) }) - // -------------------------------------------------------------------------- // Props Tests - // -------------------------------------------------------------------------- describe('Props', () => { it('should respond to isRunning prop change', () => { - // Arrange const props = createUrlInputProps({ isRunning: false }) - // Act const { rerender } = render() expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() @@ -249,11 +201,9 @@ describe('UrlInput', () => { }) it('should call updated onRun callback after prop change', async () => { - // Arrange const onRun1 = vi.fn() const onRun2 = vi.fn() - // Act const { rerender } = render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://first.com') @@ -268,15 +218,11 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // Callback Stability Tests - // -------------------------------------------------------------------------- describe('Callback Stability', () => { it('should use memoized handleUrlChange callback', async () => { - // Arrange const props = createUrlInputProps() - // Act const { rerender } = render() const input = screen.getByRole('textbox') await userEvent.type(input, 'a') @@ -290,10 +236,8 @@ describe('UrlInput', () => { }) it('should maintain URL state across rerenders', async () => { - // Arrange const props = createUrlInputProps() - // Act const { rerender } = render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://stable.com') @@ -306,58 +250,43 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // Component Memoization Tests - // -------------------------------------------------------------------------- describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Assert expect(UrlInput.$$typeof).toBeDefined() }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle very long URLs', async () => { - // Arrange const props = createUrlInputProps() const longUrl = `https://example.com/${'a'.repeat(1000)}` - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, longUrl) - // Assert expect(input).toHaveValue(longUrl) }) it('should handle URLs with unicode characters', async () => { - // Arrange const props = createUrlInputProps() const unicodeUrl = 'https://example.com/è·ŻćŸ„/æ”‹èŻ•' - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, unicodeUrl) - // Assert expect(input).toHaveValue(unicodeUrl) }) it('should handle rapid typing', async () => { - // Arrange const props = createUrlInputProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://rapid.com', { delay: 1 }) - // Assert expect(input).toHaveValue('https://rapid.com') }) @@ -366,7 +295,6 @@ describe('UrlInput', () => { const onRun = vi.fn() const props = createUrlInputProps({ onRun }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://enter.com') @@ -376,16 +304,13 @@ describe('UrlInput', () => { button.focus() await userEvent.keyboard('{Enter}') - // Assert expect(onRun).toHaveBeenCalledWith('https://enter.com') }) it('should handle empty URL submission', async () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ onRun }) - // Act render() await userEvent.click(screen.getByRole('button', { name: /run/i })) diff --git a/web/app/components/datasets/create/website/jina-reader/index.spec.tsx b/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx similarity index 91% rename from web/app/components/datasets/create/website/jina-reader/index.spec.tsx rename to web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx index fe0e0ec3af..b8829b1042 100644 --- a/web/app/components/datasets/create/website/jina-reader/index.spec.tsx +++ b/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx @@ -4,9 +4,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datasets' import { sleep } from '@/utils' -import JinaReader from './index' +import JinaReader from '../index' -// Mock external dependencies vi.mock('@/service/datasets', () => ({ createJinaReaderTask: vi.fn(), checkJinaReaderTaskStatus: vi.fn(), @@ -29,10 +28,6 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => () => 'https://docs.example.com', })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - // Note: limit and max_depth are typed as `number | string` in CrawlOptions // Tests may use number, string, or empty string values to cover all valid cases const createDefaultCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ @@ -64,9 +59,6 @@ const createDefaultProps = (overrides: Partial[0]> ...overrides, }) -// ============================================================================ -// Rendering Tests -// ============================================================================ describe('JinaReader', () => { beforeEach(() => { vi.clearAllMocks() @@ -79,95 +71,69 @@ describe('JinaReader', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.jinaReaderTitle')).toBeInTheDocument() }) it('should render header with configuration button', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.configureJinaReader')).toBeInTheDocument() }) it('should render URL input field', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render run button', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() }) it('should render options section', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument() }) it('should render doc link to Jina Reader', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert const docLink = screen.getByRole('link') expect(docLink).toHaveAttribute('href', 'https://jina.ai/reader') }) it('should not render crawling or result components initially', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument() }) }) - // ============================================================================ - // Props Testing - // ============================================================================ describe('Props', () => { it('should call onCrawlOptionsChange when options change', async () => { - // Arrange const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) const onCrawlOptionsChange = vi.fn() const props = createDefaultProps({ onCrawlOptionsChange }) - // Act render() // Find the limit input by its associated label text @@ -181,7 +147,6 @@ describe('JinaReader', () => { await user.clear(limitInput) await user.type(limitInput, '20') - // Assert expect(onCrawlOptionsChange).toHaveBeenCalled() } } @@ -192,7 +157,6 @@ describe('JinaReader', () => { }) it('should execute crawl task when checkedCrawlResult is provided', async () => { - // Arrange const checkedItem = createCrawlResultItem({ source_url: 'https://checked.com' }) const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ @@ -208,7 +172,6 @@ describe('JinaReader', () => { checkedCrawlResult: [checkedItem], }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') @@ -221,12 +184,10 @@ describe('JinaReader', () => { }) it('should use default crawlOptions limit in validation', () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: '' }), }) - // Act render() // Assert - component renders with empty limit @@ -234,12 +195,8 @@ describe('JinaReader', () => { }) }) - // ============================================================================ - // State Management Tests - // ============================================================================ describe('State Management', () => { it('should transition from init to running state when run is clicked', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock let resolvePromise: () => void const taskPromise = new Promise((resolve) => { @@ -249,12 +206,10 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const urlInput = screen.getAllByRole('textbox')[0] await userEvent.type(urlInput, 'https://example.com') - // Click run and immediately check for crawling state const runButton = screen.getByRole('button', { name: /run/i }) fireEvent.click(runButton) @@ -271,7 +226,6 @@ describe('JinaReader', () => { }) it('should transition to finished state after successful crawl', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { @@ -284,20 +238,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/selectAll|resetAll/i)).toBeInTheDocument() }) }) it('should update crawl result state during polling', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -324,13 +275,11 @@ describe('JinaReader', () => { const onJobIdChange = vi.fn() const props = createDefaultProps({ onCheckedCrawlResultChange, onJobIdChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onJobIdChange).toHaveBeenCalledWith('test-job-123') }) @@ -341,7 +290,6 @@ describe('JinaReader', () => { }) it('should fold options when step changes from init', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { @@ -354,7 +302,6 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() // Options should be visible initially @@ -371,12 +318,9 @@ describe('JinaReader', () => { }) }) - // ============================================================================ // Side Effects and Cleanup Tests - // ============================================================================ describe('Side Effects and Cleanup', () => { it('should call sleep during polling', async () => { - // Arrange const mockSleep = sleep as Mock const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -388,20 +332,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockSleep).toHaveBeenCalledWith(2500) }) }) it('should update controlFoldOptions when step changes', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock let resolvePromise: () => void const taskPromise = new Promise((resolve) => { @@ -411,7 +352,6 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() // Initially options should be visible @@ -434,20 +374,15 @@ describe('JinaReader', () => { }) }) - // ============================================================================ // Callback Stability and Memoization Tests - // ============================================================================ describe('Callback Stability', () => { it('should maintain stable handleSetting callback', () => { - // Arrange const props = createDefaultProps() - // Act const { rerender } = render() const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader') fireEvent.click(configButton) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1) // Rerender and click again @@ -458,13 +393,11 @@ describe('JinaReader', () => { }) it('should memoize checkValid callback based on crawlOptions', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValue({ data: { title: 'T', content: 'C', description: 'D', url: 'https://a.com' } }) const props = createDefaultProps() - // Act const { rerender } = render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') @@ -482,27 +415,21 @@ describe('JinaReader', () => { }) }) - // ============================================================================ // User Interactions and Event Handlers Tests - // ============================================================================ describe('User Interactions', () => { it('should open account settings when configuration button is clicked', async () => { - // Arrange const props = createDefaultProps() - // Act render() const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader') await userEvent.click(configButton) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source', }) }) it('should handle URL input and run button click', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { @@ -515,13 +442,11 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith({ url: 'https://test.com', @@ -531,7 +456,6 @@ describe('JinaReader', () => { }) it('should handle preview action on crawled result', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const onPreview = vi.fn() const crawlResultData = { @@ -545,7 +469,6 @@ describe('JinaReader', () => { const props = createDefaultProps({ onPreview }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://preview.com') @@ -556,7 +479,6 @@ describe('JinaReader', () => { expect(screen.getByText('Preview Test')).toBeInTheDocument() }) - // Click on preview button const previewButton = screen.getByText('datasetCreation.stepOne.website.preview') await userEvent.click(previewButton) @@ -564,14 +486,12 @@ describe('JinaReader', () => { }) it('should handle checkbox changes in options', async () => { - // Arrange const onCrawlOptionsChange = vi.fn() const props = createDefaultProps({ onCrawlOptionsChange, crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }), }) - // Act render() // Find and click the checkbox by data-testid @@ -583,23 +503,19 @@ describe('JinaReader', () => { }) it('should toggle options visibility when clicking options header', async () => { - // Arrange const props = createDefaultProps() - // Act render() // Options content should be visible initially expect(screen.getByText('datasetCreation.stepOne.website.crawlSubPage')).toBeInTheDocument() - // Click to collapse const optionsHeader = screen.getByText('datasetCreation.stepOne.website.options') await userEvent.click(optionsHeader) // Assert - options should be hidden expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument() - // Click to expand again await userEvent.click(optionsHeader) // Options should be visible again @@ -607,12 +523,9 @@ describe('JinaReader', () => { }) }) - // ============================================================================ // API Calls Tests - // ============================================================================ describe('API Calls', () => { it('should call createJinaReaderTask with correct parameters', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://api-test.com' }, @@ -621,13 +534,11 @@ describe('JinaReader', () => { const crawlOptions = createDefaultCrawlOptions({ limit: 5, max_depth: 3 }) const props = createDefaultProps({ crawlOptions }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://api-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith({ url: 'https://api-test.com', @@ -637,7 +548,6 @@ describe('JinaReader', () => { }) it('should handle direct data response from API', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const onCheckedCrawlResultChange = vi.fn() @@ -652,13 +562,11 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://direct.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([ expect.objectContaining({ @@ -670,7 +578,6 @@ describe('JinaReader', () => { }) it('should handle job_id response and poll for status', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onJobIdChange = vi.fn() @@ -688,13 +595,11 @@ describe('JinaReader', () => { const props = createDefaultProps({ onJobIdChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://poll-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onJobIdChange).toHaveBeenCalledWith('poll-job-123') }) @@ -705,7 +610,6 @@ describe('JinaReader', () => { }) it('should handle failed status from polling', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -717,13 +621,11 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://fail-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) @@ -732,7 +634,6 @@ describe('JinaReader', () => { }) it('should handle API error during status check', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -743,20 +644,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://error-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) it('should limit total to crawlOptions.limit', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -775,22 +673,18 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 5 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://limit-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalled() }) }) }) - // ============================================================================ // Component Memoization Tests - // ============================================================================ describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - React.memo components have $$typeof Symbol(react.memo) @@ -799,15 +693,11 @@ describe('JinaReader', () => { }) }) - // ============================================================================ // Edge Cases and Error Handling Tests - // ============================================================================ describe('Edge Cases and Error Handling', () => { it('should show error for empty URL', async () => { - // Arrange const props = createDefaultProps() - // Act render() await userEvent.click(screen.getByRole('button', { name: /run/i })) @@ -818,39 +708,32 @@ describe('JinaReader', () => { }) it('should show error for invalid URL format', async () => { - // Arrange const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'invalid-url') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createJinaReaderTask).not.toHaveBeenCalled() }) }) it('should show error for URL without protocol', async () => { - // Arrange const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createJinaReaderTask).not.toHaveBeenCalled() }) }) it('should accept URL with http:// protocol', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'http://example.com' }, @@ -858,74 +741,62 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'http://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) it('should show error when limit is empty', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: '' }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createJinaReaderTask).not.toHaveBeenCalled() }) }) it('should show error when limit is null', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: null as unknown as number }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createJinaReaderTask).not.toHaveBeenCalled() }) }) it('should show error when limit is undefined', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: undefined as unknown as number }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createJinaReaderTask).not.toHaveBeenCalled() }) }) it('should handle API throwing an exception', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockRejectedValueOnce(new Error('Network error')) // Suppress console output during test to avoid noisy logs @@ -933,13 +804,11 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://exception-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) @@ -948,7 +817,6 @@ describe('JinaReader', () => { }) it('should handle status response without status field', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -960,20 +828,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://no-status-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) it('should show unknown error when error message is empty', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -985,20 +850,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://empty-error-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.unknownError')).toBeInTheDocument() }) }) it('should handle empty data array from API', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1013,20 +875,17 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://empty-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should handle null data from running status', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1048,20 +907,17 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://null-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should return empty array when completed job has undefined data', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1076,20 +932,17 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://undefined-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should show zero current progress when crawlResult is not yet available', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock let resolveCheckStatus: () => void @@ -1104,7 +957,6 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 10 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://zero-current-test.com') @@ -1123,7 +975,6 @@ describe('JinaReader', () => { }) it('should show 0/0 progress when limit is zero string', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock let resolveCheckStatus: () => void @@ -1138,7 +989,6 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: '0' }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://zero-total-test.com') @@ -1157,7 +1007,6 @@ describe('JinaReader', () => { }) it('should complete successfully when result data is undefined', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1173,7 +1022,6 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://undefined-result-data-test.com') @@ -1186,7 +1034,6 @@ describe('JinaReader', () => { }) it('should use limit as total when crawlResult total is not available', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock let resolveCheckStatus: () => void @@ -1201,7 +1048,6 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 15 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://no-total-test.com') @@ -1220,7 +1066,6 @@ describe('JinaReader', () => { }) it('should fallback to limit when crawlResult has zero total', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock let resolveCheckStatus: () => void @@ -1242,7 +1087,6 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 5 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://both-zero-test.com') @@ -1261,7 +1105,6 @@ describe('JinaReader', () => { }) it('should construct result item from direct data response', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1276,7 +1119,6 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://direct-array.com') @@ -1294,12 +1136,8 @@ describe('JinaReader', () => { }) }) - // ============================================================================ - // All Prop Variations Tests - // ============================================================================ describe('Prop Variations', () => { it('should handle different limit values in crawlOptions', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://limit.com' }, @@ -1309,13 +1147,11 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 100 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://limit.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1326,7 +1162,6 @@ describe('JinaReader', () => { }) it('should handle different max_depth values', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://depth.com' }, @@ -1336,13 +1171,11 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ max_depth: 5 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://depth.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1353,7 +1186,6 @@ describe('JinaReader', () => { }) it('should handle crawl_sub_pages disabled', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://nosub.com' }, @@ -1363,13 +1195,11 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://nosub.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1380,7 +1210,6 @@ describe('JinaReader', () => { }) it('should handle use_sitemap enabled', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://sitemap.com' }, @@ -1390,13 +1219,11 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ use_sitemap: true }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://sitemap.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1407,7 +1234,6 @@ describe('JinaReader', () => { }) it('should handle includes and excludes patterns', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://patterns.com' }, @@ -1420,13 +1246,11 @@ describe('JinaReader', () => { }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://patterns.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1440,7 +1264,6 @@ describe('JinaReader', () => { }) it('should handle pre-selected crawl results', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const existingResult = createCrawlResultItem({ source_url: 'https://existing.com' }) @@ -1452,20 +1275,17 @@ describe('JinaReader', () => { checkedCrawlResult: [existingResult], }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://new.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) it('should handle string type limit value', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://string-limit.com' }, @@ -1475,25 +1295,20 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: '25' }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://string-limit.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) }) - // ============================================================================ // Display and UI State Tests - // ============================================================================ describe('Display and UI States', () => { it('should show crawling progress during running state', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock let resolveCheckStatus: () => void @@ -1508,13 +1323,11 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 10 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://progress.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument() }) @@ -1527,7 +1340,6 @@ describe('JinaReader', () => { }) it('should display time consumed after crawl completion', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ @@ -1536,20 +1348,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://time.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument() }) }) it('should display crawled results list after completion', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ @@ -1563,20 +1372,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://result.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('Result Page')).toBeInTheDocument() }) }) it('should show error message component when crawl fails', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockRejectedValueOnce(new Error('Failed')) @@ -1585,25 +1391,19 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://fail.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) }) - // ============================================================================ - // Integration Tests - // ============================================================================ describe('Integration', () => { it('should complete full crawl workflow with job polling', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1641,7 +1441,6 @@ describe('JinaReader', () => { onPreview, }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://full-workflow.com') @@ -1668,7 +1467,6 @@ describe('JinaReader', () => { }) it('should handle select all and deselect all in results', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1678,7 +1476,6 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://single.com') @@ -1689,11 +1486,9 @@ describe('JinaReader', () => { expect(screen.getByText('Single')).toBeInTheDocument() }) - // Click select all/reset all const selectAllCheckbox = screen.getByText(/selectAll|resetAll/i) await userEvent.click(selectAllCheckbox) - // Assert expect(onCheckedCrawlResultChange).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx b/web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx new file mode 100644 index 0000000000..570332aae3 --- /dev/null +++ b/web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx @@ -0,0 +1,191 @@ +import type { CrawlOptions } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Options from '../options' + +// Test Data Factory + +const createMockCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ + crawl_sub_pages: true, + limit: 10, + max_depth: 2, + excludes: '', + includes: '', + only_main_content: false, + use_sitemap: false, + ...overrides, +}) + +// Jina Reader Options Component Tests + +describe('Options (jina-reader)', () => { + const mockOnChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + const getCheckboxes = (container: HTMLElement) => { + return container.querySelectorAll('[data-testid^="checkbox-"]') + } + + describe('Rendering', () => { + it('should render crawlSubPage and useSitemap checkboxes and limit field', () => { + const payload = createMockCrawlOptions() + render() + + expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument() + expect(screen.getByText(/useSitemap/i)).toBeInTheDocument() + expect(screen.getByText(/limit/i)).toBeInTheDocument() + }) + + it('should render two checkboxes', () => { + const payload = createMockCrawlOptions() + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes.length).toBe(2) + }) + + it('should render limit field with required indicator', () => { + const payload = createMockCrawlOptions() + render() + + const requiredIndicator = screen.getByText('*') + expect(requiredIndicator).toBeInTheDocument() + }) + + it('should render with custom className', () => { + const payload = createMockCrawlOptions() + const { container } = render( + , + ) + + const rootElement = container.firstChild as HTMLElement + expect(rootElement).toHaveClass('custom-class') + }) + }) + + // Props Display Tests + describe('Props Display', () => { + it('should display crawl_sub_pages checkbox with check icon when true', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[0].querySelector('svg')).toBeInTheDocument() + }) + + it('should display crawl_sub_pages checkbox without check icon when false', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument() + }) + + it('should display use_sitemap checkbox with check icon when true', () => { + const payload = createMockCrawlOptions({ use_sitemap: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[1].querySelector('svg')).toBeInTheDocument() + }) + + it('should display use_sitemap checkbox without check icon when false', () => { + const payload = createMockCrawlOptions({ use_sitemap: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument() + }) + + it('should display limit value in input', () => { + const payload = createMockCrawlOptions({ limit: 25 }) + render() + + expect(screen.getByDisplayValue('25')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + fireEvent.click(checkboxes[0]) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + crawl_sub_pages: false, + }) + }) + + it('should call onChange with updated use_sitemap when checkbox is clicked', () => { + const payload = createMockCrawlOptions({ use_sitemap: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + fireEvent.click(checkboxes[1]) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + use_sitemap: true, + }) + }) + + it('should call onChange with updated limit when input changes', () => { + const payload = createMockCrawlOptions({ limit: 10 }) + render() + + const limitInput = screen.getByDisplayValue('10') + fireEvent.change(limitInput, { target: { value: '50' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + limit: 50, + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle zero limit value', () => { + const payload = createMockCrawlOptions({ limit: 0 }) + render() + + const zeroInputs = screen.getAllByDisplayValue('0') + expect(zeroInputs.length).toBeGreaterThanOrEqual(1) + }) + + it('should preserve other payload fields when updating one field', () => { + const payload = createMockCrawlOptions({ + crawl_sub_pages: true, + limit: 10, + use_sitemap: true, + }) + render() + + const limitInput = screen.getByDisplayValue('10') + fireEvent.change(limitInput, { target: { value: '20' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + limit: 20, + }) + }) + }) + + describe('Memoization', () => { + it('should re-render when payload changes', () => { + const payload1 = createMockCrawlOptions({ limit: 10 }) + const payload2 = createMockCrawlOptions({ limit: 20 }) + + const { rerender } = render() + expect(screen.getByDisplayValue('10')).toBeInTheDocument() + + rerender() + expect(screen.getByDisplayValue('20')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/website/jina-reader/base/__tests__/url-input.spec.tsx b/web/app/components/datasets/create/website/jina-reader/base/__tests__/url-input.spec.tsx new file mode 100644 index 0000000000..296d5c091b --- /dev/null +++ b/web/app/components/datasets/create/website/jina-reader/base/__tests__/url-input.spec.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Component Imports (after mocks) + +import UrlInput from '../url-input' + +// Mock Setup + +vi.mock('@/context/i18n', () => ({ + useDocLink: vi.fn(() => () => 'https://docs.example.com'), +})) + +// Jina Reader UrlInput Component Tests + +describe('UrlInput (jina-reader)', () => { + const mockOnRun = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render input and run button', () => { + render() + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render input with placeholder from docLink', () => { + render() + const input = screen.getByRole('textbox') + expect(input).toHaveAttribute('placeholder', 'https://docs.example.com') + }) + + it('should show run text when not running', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveTextContent(/run/i) + }) + + it('should hide run text when running', () => { + render() + const button = screen.getByRole('button') + expect(button).not.toHaveTextContent(/run/i) + }) + + it('should show loading state on button when running', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveTextContent(/loading/i) + }) + + it('should not show loading state on button when not running', () => { + render() + const button = screen.getByRole('button') + expect(button).not.toHaveTextContent(/loading/i) + }) + }) + + describe('User Interactions', () => { + it('should update url when user types in input', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://example.com') + + expect(input).toHaveValue('https://example.com') + }) + + it('should call onRun with url when run button clicked and not running', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://example.com') + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockOnRun).toHaveBeenCalledWith('https://example.com') + expect(mockOnRun).toHaveBeenCalledTimes(1) + }) + + it('should NOT call onRun when isRunning is true', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'https://example.com' } }) + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockOnRun).not.toHaveBeenCalled() + }) + + it('should call onRun with empty string when button clicked with empty input', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockOnRun).toHaveBeenCalledWith('') + }) + }) + + // Props Variations Tests + describe('Props Variations', () => { + it('should update button state when isRunning changes from false to true', () => { + const { rerender } = render() + + expect(screen.getByRole('button')).toHaveTextContent(/run/i) + + rerender() + + expect(screen.getByRole('button')).not.toHaveTextContent(/run/i) + }) + + it('should preserve input value when isRunning prop changes', async () => { + const user = userEvent.setup() + const { rerender } = render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://preserved.com') + expect(input).toHaveValue('https://preserved.com') + + rerender() + expect(input).toHaveValue('https://preserved.com') + }) + }) + + describe('Edge Cases', () => { + it('should handle special characters in url', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + const specialUrl = 'https://example.com/path?query=test¶m=value#anchor' + await user.type(input, specialUrl) + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockOnRun).toHaveBeenCalledWith(specialUrl) + }) + + it('should handle rapid input changes', () => { + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'a' } }) + fireEvent.change(input, { target: { value: 'ab' } }) + fireEvent.change(input, { target: { value: 'https://final.com' } }) + + expect(input).toHaveValue('https://final.com') + + fireEvent.click(screen.getByRole('button')) + expect(mockOnRun).toHaveBeenCalledWith('https://final.com') + }) + }) + + describe('Integration', () => { + it('should complete full workflow: type url -> click run -> verify callback', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://mywebsite.com') + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockOnRun).toHaveBeenCalledWith('https://mywebsite.com') + }) + + it('should show correct states during running workflow', () => { + const { rerender } = render() + + expect(screen.getByRole('button')).toHaveTextContent(/run/i) + + rerender() + expect(screen.getByRole('button')).not.toHaveTextContent(/run/i) + + rerender() + expect(screen.getByRole('button')).toHaveTextContent(/run/i) + }) + }) +}) diff --git a/web/app/components/datasets/create/website/watercrawl/index.spec.tsx b/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx similarity index 91% rename from web/app/components/datasets/create/website/watercrawl/index.spec.tsx rename to web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx index c3caab895a..5ff2d8efb8 100644 --- a/web/app/components/datasets/create/website/watercrawl/index.spec.tsx +++ b/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx @@ -7,9 +7,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { checkWatercrawlTaskStatus, createWatercrawlTask } from '@/service/datasets' import { sleep } from '@/utils' -import WaterCrawl from './index' +import WaterCrawl from '../index' -// Mock external dependencies vi.mock('@/service/datasets', () => ({ createWatercrawlTask: vi.fn(), checkWatercrawlTaskStatus: vi.fn(), @@ -32,10 +31,6 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path?: string) => path ? `https://docs.dify.ai/en${path}` : 'https://docs.dify.ai/en/', })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - // Note: limit and max_depth are typed as `number | string` in CrawlOptions // Tests may use number, string, or empty string values to cover all valid cases const createDefaultCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ @@ -67,9 +62,6 @@ const createDefaultProps = (overrides: Partial[0]> ...overrides, }) -// ============================================================================ -// Rendering Tests -// ============================================================================ describe('WaterCrawl', () => { beforeEach(() => { vi.clearAllMocks() @@ -84,32 +76,24 @@ describe('WaterCrawl', () => { // Tests for initial component rendering describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.watercrawlTitle')).toBeInTheDocument() }) it('should render header with configuration button', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.configureWatercrawl')).toBeInTheDocument() }) it('should render URL input field', () => { - // Arrange const props = createDefaultProps() - // Act render() // Assert - URL input has specific placeholder @@ -117,62 +101,45 @@ describe('WaterCrawl', () => { }) it('should render run button', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() }) it('should render options section', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument() }) it('should render doc link to WaterCrawl', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert const docLink = screen.getByRole('link') expect(docLink).toHaveAttribute('href', 'https://docs.watercrawl.dev/') }) it('should not render crawling or result components initially', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument() }) }) - // ============================================================================ - // Props Testing - // ============================================================================ describe('Props', () => { it('should call onCrawlOptionsChange when options change', async () => { - // Arrange const user = userEvent.setup() const onCrawlOptionsChange = vi.fn() const props = createDefaultProps({ onCrawlOptionsChange }) - // Act render() // Find the limit input by its associated label text @@ -186,7 +153,6 @@ describe('WaterCrawl', () => { await user.clear(limitInput) await user.type(limitInput, '20') - // Assert expect(onCrawlOptionsChange).toHaveBeenCalled() } } @@ -197,7 +163,6 @@ describe('WaterCrawl', () => { }) it('should execute crawl task when checkedCrawlResult is provided', async () => { - // Arrange const checkedItem = createCrawlResultItem({ source_url: 'https://checked.com' }) const mockCreateTask = createWatercrawlTask as Mock mockCreateTask.mockResolvedValueOnce({ job_id: 'test-job' }) @@ -214,7 +179,6 @@ describe('WaterCrawl', () => { checkedCrawlResult: [checkedItem], }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') @@ -227,12 +191,10 @@ describe('WaterCrawl', () => { }) it('should use default crawlOptions limit in validation', () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: '' }), }) - // Act render() // Assert - component renders with empty limit @@ -240,12 +202,8 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ - // State Management Tests - // ============================================================================ describe('State Management', () => { it('should transition from init to running state when run is clicked', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock let resolvePromise: () => void mockCreateTask.mockImplementation(() => new Promise((resolve) => { @@ -254,12 +212,10 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const urlInput = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(urlInput, 'https://example.com') - // Click run and immediately check for crawling state const runButton = screen.getByRole('button', { name: /run/i }) fireEvent.click(runButton) @@ -273,7 +229,6 @@ describe('WaterCrawl', () => { }) it('should transition to finished state after successful crawl', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -287,20 +242,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/selectAll|resetAll/i)).toBeInTheDocument() }) }) it('should update crawl result state during polling', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -327,13 +279,11 @@ describe('WaterCrawl', () => { const onJobIdChange = vi.fn() const props = createDefaultProps({ onCheckedCrawlResultChange, onJobIdChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onJobIdChange).toHaveBeenCalledWith('test-job-123') }) @@ -344,7 +294,6 @@ describe('WaterCrawl', () => { }) it('should fold options when step changes from init', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -358,7 +307,6 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() // Options should be visible initially @@ -375,12 +323,9 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // Side Effects and Cleanup Tests - // ============================================================================ describe('Side Effects and Cleanup', () => { it('should call sleep during polling', async () => { - // Arrange const mockSleep = sleep as Mock const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -392,26 +337,22 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockSleep).toHaveBeenCalledWith(2500) }) }) it('should update controlFoldOptions when step changes', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock mockCreateTask.mockImplementation(() => new Promise(() => { /* pending */ })) const props = createDefaultProps() - // Act render() // Initially options should be visible @@ -428,20 +369,15 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // Callback Stability and Memoization Tests - // ============================================================================ describe('Callback Stability', () => { it('should maintain stable handleSetting callback', () => { - // Arrange const props = createDefaultProps() - // Act const { rerender } = render() const configButton = screen.getByText('datasetCreation.stepOne.website.configureWatercrawl') fireEvent.click(configButton) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1) // Rerender and click again @@ -452,7 +388,6 @@ describe('WaterCrawl', () => { }) it('should memoize checkValid callback based on crawlOptions', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -466,7 +401,6 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act const { rerender } = render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') @@ -484,27 +418,21 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // User Interactions and Event Handlers Tests - // ============================================================================ describe('User Interactions', () => { it('should open account settings when configuration button is clicked', async () => { - // Arrange const props = createDefaultProps() - // Act render() const configButton = screen.getByText('datasetCreation.stepOne.website.configureWatercrawl') await userEvent.click(configButton) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source', }) }) it('should handle URL input and run button click', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -518,13 +446,11 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith({ url: 'https://test.com', @@ -534,7 +460,6 @@ describe('WaterCrawl', () => { }) it('should handle preview action on crawled result', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onPreview = vi.fn() @@ -549,7 +474,6 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onPreview }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://preview.com') @@ -560,7 +484,6 @@ describe('WaterCrawl', () => { expect(screen.getByText('Preview Test')).toBeInTheDocument() }) - // Click on preview button const previewButton = screen.getByText('datasetCreation.stepOne.website.preview') await userEvent.click(previewButton) @@ -568,14 +491,12 @@ describe('WaterCrawl', () => { }) it('should handle checkbox changes in options', async () => { - // Arrange const onCrawlOptionsChange = vi.fn() const props = createDefaultProps({ onCrawlOptionsChange, crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }), }) - // Act render() // Find and click the checkbox by data-testid @@ -587,23 +508,19 @@ describe('WaterCrawl', () => { }) it('should toggle options visibility when clicking options header', async () => { - // Arrange const props = createDefaultProps() - // Act render() // Options content should be visible initially expect(screen.getByText('datasetCreation.stepOne.website.crawlSubPage')).toBeInTheDocument() - // Click to collapse const optionsHeader = screen.getByText('datasetCreation.stepOne.website.options') await userEvent.click(optionsHeader) // Assert - options should be hidden expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument() - // Click to expand again await userEvent.click(optionsHeader) // Options should be visible again @@ -611,12 +528,9 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // API Calls Tests - // ============================================================================ describe('API Calls', () => { it('should call createWatercrawlTask with correct parameters', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -631,13 +545,11 @@ describe('WaterCrawl', () => { const crawlOptions = createDefaultCrawlOptions({ limit: 5, max_depth: 3 }) const props = createDefaultProps({ crawlOptions }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://api-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith({ url: 'https://api-test.com', @@ -647,7 +559,6 @@ describe('WaterCrawl', () => { }) it('should delete max_depth from options when it is empty string', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -662,7 +573,6 @@ describe('WaterCrawl', () => { const crawlOptions = createDefaultCrawlOptions({ max_depth: '' }) const props = createDefaultProps({ crawlOptions }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://test.com') @@ -676,7 +586,6 @@ describe('WaterCrawl', () => { }) it('should poll for status with job_id', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onJobIdChange = vi.fn() @@ -694,13 +603,11 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onJobIdChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://poll-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onJobIdChange).toHaveBeenCalledWith('poll-job-123') }) @@ -711,7 +618,6 @@ describe('WaterCrawl', () => { }) it('should handle error status from polling', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -723,13 +629,11 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://fail-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) @@ -738,7 +642,6 @@ describe('WaterCrawl', () => { }) it('should handle API error during status check', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -749,20 +652,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://error-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) it('should limit total to crawlOptions.limit', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -781,20 +681,17 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 5 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://limit-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalled() }) }) it('should handle response without status field as error', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -806,22 +703,18 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://no-status-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) }) - // ============================================================================ // Component Memoization Tests - // ============================================================================ describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - React.memo components have $$typeof Symbol(react.memo) @@ -830,15 +723,11 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // Edge Cases and Error Handling Tests - // ============================================================================ describe('Edge Cases and Error Handling', () => { it('should show error for empty URL', async () => { - // Arrange const props = createDefaultProps() - // Act render() await userEvent.click(screen.getByRole('button', { name: /run/i })) @@ -849,39 +738,32 @@ describe('WaterCrawl', () => { }) it('should show error for invalid URL format', async () => { - // Arrange const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'invalid-url') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createWatercrawlTask).not.toHaveBeenCalled() }) }) it('should show error for URL without protocol', async () => { - // Arrange const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createWatercrawlTask).not.toHaveBeenCalled() }) }) it('should accept URL with http:// protocol', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -895,74 +777,62 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'http://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) it('should show error when limit is empty', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: '' }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createWatercrawlTask).not.toHaveBeenCalled() }) }) it('should show error when limit is null', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: null as unknown as number }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createWatercrawlTask).not.toHaveBeenCalled() }) }) it('should show error when limit is undefined', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: undefined as unknown as number }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createWatercrawlTask).not.toHaveBeenCalled() }) }) it('should handle API throwing an exception', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock mockCreateTask.mockRejectedValueOnce(new Error('Network error')) // Suppress console output during test to avoid noisy logs @@ -970,13 +840,11 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://exception-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) @@ -985,7 +853,6 @@ describe('WaterCrawl', () => { }) it('should show unknown error when error message is empty', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -997,20 +864,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://empty-error-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.unknownError')).toBeInTheDocument() }) }) it('should handle empty data array from API', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1025,20 +889,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://empty-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should handle null data from running status', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1060,20 +921,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://null-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should handle undefined data from completed job polling', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1088,20 +946,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://undefined-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should handle crawlResult with zero current value', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1112,7 +967,6 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 10 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://zero-current-test.com') @@ -1125,7 +979,6 @@ describe('WaterCrawl', () => { }) it('should handle crawlResult with zero total and empty limit', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1136,7 +989,6 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: '0' }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://zero-total-test.com') @@ -1149,7 +1001,6 @@ describe('WaterCrawl', () => { }) it('should handle undefined crawlResult data in finished state', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1165,7 +1016,6 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://undefined-result-data-test.com') @@ -1178,7 +1028,6 @@ describe('WaterCrawl', () => { }) it('should use parseFloat fallback when crawlResult.total is undefined', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1189,7 +1038,6 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 15 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://no-total-test.com') @@ -1202,7 +1050,6 @@ describe('WaterCrawl', () => { }) it('should handle crawlResult with current=0 and total=0 during running', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1220,25 +1067,19 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 5 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://both-zero-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/totalPageScraped/)).toBeInTheDocument() }) }) }) - // ============================================================================ - // All Prop Variations Tests - // ============================================================================ describe('Prop Variations', () => { it('should handle different limit values in crawlOptions', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1254,13 +1095,11 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 100 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://limit.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1271,7 +1110,6 @@ describe('WaterCrawl', () => { }) it('should handle different max_depth values', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1287,13 +1125,11 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ max_depth: 5 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://depth.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1304,7 +1140,6 @@ describe('WaterCrawl', () => { }) it('should handle crawl_sub_pages disabled', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1320,13 +1155,11 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://nosub.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1337,7 +1170,6 @@ describe('WaterCrawl', () => { }) it('should handle use_sitemap enabled', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1353,13 +1185,11 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ use_sitemap: true }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://sitemap.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1370,7 +1200,6 @@ describe('WaterCrawl', () => { }) it('should handle includes and excludes patterns', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1389,13 +1218,11 @@ describe('WaterCrawl', () => { }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://patterns.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1409,7 +1236,6 @@ describe('WaterCrawl', () => { }) it('should handle pre-selected crawl results', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const existingResult = createCrawlResultItem({ source_url: 'https://existing.com' }) @@ -1426,20 +1252,17 @@ describe('WaterCrawl', () => { checkedCrawlResult: [existingResult], }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://new.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) it('should handle string type limit value', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1455,20 +1278,17 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: '25' }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://string-limit.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) it('should handle only_main_content option', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1484,13 +1304,11 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ only_main_content: false }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://main-content.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1501,12 +1319,9 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // Display and UI State Tests - // ============================================================================ describe('Display and UI States', () => { it('should show crawling progress during running state', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1517,20 +1332,17 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 10 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://progress.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument() }) }) it('should display time consumed after crawl completion', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1545,20 +1357,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://time.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument() }) }) it('should display crawled results list after completion', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1572,20 +1381,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://result.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('Result Page')).toBeInTheDocument() }) }) it('should show error message component when crawl fails', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock mockCreateTask.mockRejectedValueOnce(new Error('Failed')) @@ -1594,20 +1400,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://fail.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) it('should update progress during multiple polling iterations', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1643,7 +1446,6 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 10 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://multi-poll.com') @@ -1665,12 +1467,8 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ - // Integration Tests - // ============================================================================ describe('Integration', () => { it('should complete full crawl workflow with job polling', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1708,7 +1506,6 @@ describe('WaterCrawl', () => { onPreview, }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://full-workflow.com') @@ -1735,7 +1532,6 @@ describe('WaterCrawl', () => { }) it('should handle select all and deselect all in results', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1750,7 +1546,6 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://single.com') @@ -1761,16 +1556,13 @@ describe('WaterCrawl', () => { expect(screen.getByText('Single')).toBeInTheDocument() }) - // Click select all/reset all const selectAllCheckbox = screen.getByText(/selectAll|resetAll/i) await userEvent.click(selectAllCheckbox) - // Assert expect(onCheckedCrawlResultChange).toHaveBeenCalled() }) it('should handle complete workflow from input to preview', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onPreview = vi.fn() @@ -1796,7 +1588,6 @@ describe('WaterCrawl', () => { onJobIdChange, }) - // Act render() // Step 1: Enter URL @@ -1815,7 +1606,6 @@ describe('WaterCrawl', () => { const previewButton = screen.getByText('datasetCreation.stepOne.website.preview') await userEvent.click(previewButton) - // Assert expect(onJobIdChange).toHaveBeenCalledWith('preview-workflow-job') expect(onCheckedCrawlResultChange).toHaveBeenCalled() expect(onPreview).toHaveBeenCalled() diff --git a/web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx b/web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx new file mode 100644 index 0000000000..20843db82f --- /dev/null +++ b/web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx @@ -0,0 +1,276 @@ +import type { CrawlOptions } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Options from '../options' + +// Test Data Factory + +const createMockCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ + crawl_sub_pages: true, + limit: 10, + max_depth: 2, + excludes: '', + includes: '', + only_main_content: false, + use_sitemap: false, + ...overrides, +}) + +// WaterCrawl Options Component Tests + +describe('Options (watercrawl)', () => { + const mockOnChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + const getCheckboxes = (container: HTMLElement) => { + return container.querySelectorAll('[data-testid^="checkbox-"]') + } + + describe('Rendering', () => { + it('should render all form fields', () => { + const payload = createMockCrawlOptions() + render() + + expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument() + expect(screen.getByText(/extractOnlyMainContent/i)).toBeInTheDocument() + expect(screen.getByText(/limit/i)).toBeInTheDocument() + expect(screen.getByText(/maxDepth/i)).toBeInTheDocument() + expect(screen.getByText(/excludePaths/i)).toBeInTheDocument() + expect(screen.getByText(/includeOnlyPaths/i)).toBeInTheDocument() + }) + + it('should render two checkboxes', () => { + const payload = createMockCrawlOptions() + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes.length).toBe(2) + }) + + it('should render limit field with required indicator', () => { + const payload = createMockCrawlOptions() + render() + + const requiredIndicator = screen.getByText('*') + expect(requiredIndicator).toBeInTheDocument() + }) + + it('should render placeholder for excludes field', () => { + const payload = createMockCrawlOptions() + render() + + expect(screen.getByPlaceholderText('blog/*, /about/*')).toBeInTheDocument() + }) + + it('should render placeholder for includes field', () => { + const payload = createMockCrawlOptions() + render() + + expect(screen.getByPlaceholderText('articles/*')).toBeInTheDocument() + }) + + it('should render with custom className', () => { + const payload = createMockCrawlOptions() + const { container } = render( + , + ) + + const rootElement = container.firstChild as HTMLElement + expect(rootElement).toHaveClass('custom-class') + }) + }) + + // Props Display Tests + describe('Props Display', () => { + it('should display crawl_sub_pages checkbox with check icon when true', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[0].querySelector('svg')).toBeInTheDocument() + }) + + it('should display crawl_sub_pages checkbox without check icon when false', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument() + }) + + it('should display only_main_content checkbox with check icon when true', () => { + const payload = createMockCrawlOptions({ only_main_content: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[1].querySelector('svg')).toBeInTheDocument() + }) + + it('should display only_main_content checkbox without check icon when false', () => { + const payload = createMockCrawlOptions({ only_main_content: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument() + }) + + it('should display limit value in input', () => { + const payload = createMockCrawlOptions({ limit: 25 }) + render() + + expect(screen.getByDisplayValue('25')).toBeInTheDocument() + }) + + it('should display max_depth value in input', () => { + const payload = createMockCrawlOptions({ max_depth: 5 }) + render() + + expect(screen.getByDisplayValue('5')).toBeInTheDocument() + }) + + it('should display excludes value in input', () => { + const payload = createMockCrawlOptions({ excludes: 'test/*' }) + render() + + expect(screen.getByDisplayValue('test/*')).toBeInTheDocument() + }) + + it('should display includes value in input', () => { + const payload = createMockCrawlOptions({ includes: 'docs/*' }) + render() + + expect(screen.getByDisplayValue('docs/*')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + fireEvent.click(checkboxes[0]) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + crawl_sub_pages: false, + }) + }) + + it('should call onChange with updated only_main_content when checkbox is clicked', () => { + const payload = createMockCrawlOptions({ only_main_content: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + fireEvent.click(checkboxes[1]) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + only_main_content: true, + }) + }) + + it('should call onChange with updated limit when input changes', () => { + const payload = createMockCrawlOptions({ limit: 10 }) + render() + + const limitInput = screen.getByDisplayValue('10') + fireEvent.change(limitInput, { target: { value: '50' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + limit: 50, + }) + }) + + it('should call onChange with updated max_depth when input changes', () => { + const payload = createMockCrawlOptions({ max_depth: 2 }) + render() + + const maxDepthInput = screen.getByDisplayValue('2') + fireEvent.change(maxDepthInput, { target: { value: '10' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + max_depth: 10, + }) + }) + + it('should call onChange with updated excludes when input changes', () => { + const payload = createMockCrawlOptions({ excludes: '' }) + render() + + const excludesInput = screen.getByPlaceholderText('blog/*, /about/*') + fireEvent.change(excludesInput, { target: { value: 'admin/*' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + excludes: 'admin/*', + }) + }) + + it('should call onChange with updated includes when input changes', () => { + const payload = createMockCrawlOptions({ includes: '' }) + render() + + const includesInput = screen.getByPlaceholderText('articles/*') + fireEvent.change(includesInput, { target: { value: 'public/*' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + includes: 'public/*', + }) + }) + }) + + describe('Edge Cases', () => { + it('should preserve other payload fields when updating one field', () => { + const payload = createMockCrawlOptions({ + crawl_sub_pages: true, + limit: 10, + max_depth: 2, + excludes: 'test/*', + includes: 'docs/*', + only_main_content: true, + }) + render() + + const limitInput = screen.getByDisplayValue('10') + fireEvent.change(limitInput, { target: { value: '20' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + crawl_sub_pages: true, + limit: 20, + max_depth: 2, + excludes: 'test/*', + includes: 'docs/*', + only_main_content: true, + use_sitemap: false, + }) + }) + + it('should handle zero values', () => { + const payload = createMockCrawlOptions({ limit: 0, max_depth: 0 }) + render() + + const zeroInputs = screen.getAllByDisplayValue('0') + expect(zeroInputs.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Memoization', () => { + it('should re-render when payload changes', () => { + const payload1 = createMockCrawlOptions({ limit: 10 }) + const payload2 = createMockCrawlOptions({ limit: 20 }) + + const { rerender } = render() + expect(screen.getByDisplayValue('10')).toBeInTheDocument() + + rerender() + expect(screen.getByDisplayValue('20')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/index.spec.tsx b/web/app/components/datasets/documents/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/index.spec.tsx rename to web/app/components/datasets/documents/__tests__/index.spec.tsx index c2f1538056..1749508ee1 100644 --- a/web/app/components/datasets/documents/index.spec.tsx +++ b/web/app/components/datasets/documents/__tests__/index.spec.tsx @@ -4,8 +4,8 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useProviderContext } from '@/context/provider-context' import { DataSourceType } from '@/models/datasets' import { useDocumentList } from '@/service/knowledge/use-document' -import useDocumentsPageState from './hooks/use-documents-page-state' -import Documents from './index' +import useDocumentsPageState from '../hooks/use-documents-page-state' +import Documents from '../index' // Type for mock selector function - use `as MockState` to bypass strict type checking in tests type MockSelector = Parameters[0] @@ -94,7 +94,7 @@ vi.mock('@/service/use-base', () => ({ })) // Mock metadata hook -vi.mock('../metadata/hooks/use-edit-dataset-metadata', () => ({ +vi.mock('../../metadata/hooks/use-edit-dataset-metadata', () => ({ default: vi.fn(() => ({ isShowEditModal: false, showEditModal: vi.fn(), @@ -120,7 +120,7 @@ const mockHandleLimitChange = vi.fn() const mockUpdatePollingState = vi.fn() const mockAdjustPageForTotal = vi.fn() -vi.mock('./hooks/use-documents-page-state', () => ({ +vi.mock('../hooks/use-documents-page-state', () => ({ default: vi.fn(() => ({ inputValue: '', searchValue: '', @@ -146,7 +146,7 @@ vi.mock('./hooks/use-documents-page-state', () => ({ // Mock child components - these have deep dependency chains (QueryClient, API hooks, contexts) // Mocking them allows us to test the Documents component logic in isolation -vi.mock('./components/documents-header', () => ({ +vi.mock('../components/documents-header', () => ({ default: ({ datasetId, embeddingAvailable, @@ -203,7 +203,7 @@ vi.mock('./components/documents-header', () => ({ ), })) -vi.mock('./components/empty-element', () => ({ +vi.mock('../components/empty-element', () => ({ default: ({ canAdd, onClick, type }: { canAdd: boolean onClick: () => void @@ -219,7 +219,7 @@ vi.mock('./components/empty-element', () => ({ ), })) -vi.mock('./components/list', () => ({ +vi.mock('../components/list', () => ({ default: ({ documents, datasetId, diff --git a/web/app/components/datasets/documents/__tests__/status-filter.spec.ts b/web/app/components/datasets/documents/__tests__/status-filter.spec.ts new file mode 100644 index 0000000000..c18f4ef688 --- /dev/null +++ b/web/app/components/datasets/documents/__tests__/status-filter.spec.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { normalizeStatusForQuery, sanitizeStatusValue } from '../status-filter' + +vi.mock('@/models/datasets', () => ({ + DisplayStatusList: [ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'enabled', + 'disabled', + 'archived', + ], +})) + +describe('status-filter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for sanitizeStatusValue + describe('sanitizeStatusValue', () => { + // Falsy inputs should return 'all' + describe('falsy inputs', () => { + it('should return all when value is undefined', () => { + expect(sanitizeStatusValue(undefined)).toBe('all') + }) + + it('should return all when value is null', () => { + expect(sanitizeStatusValue(null)).toBe('all') + }) + + it('should return all when value is empty string', () => { + expect(sanitizeStatusValue('')).toBe('all') + }) + }) + + // Known status values should be returned as-is (lowercased) + describe('known status values', () => { + it('should return all when value is all', () => { + expect(sanitizeStatusValue('all')).toBe('all') + }) + + it.each([ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'enabled', + 'disabled', + 'archived', + ])('should return %s when value is %s', (status) => { + expect(sanitizeStatusValue(status)).toBe(status) + }) + + it('should handle uppercase known values by normalizing to lowercase', () => { + expect(sanitizeStatusValue('QUEUING')).toBe('queuing') + expect(sanitizeStatusValue('Available')).toBe('available') + expect(sanitizeStatusValue('ALL')).toBe('all') + }) + }) + + // URL alias resolution + describe('URL aliases', () => { + it('should resolve active to available', () => { + expect(sanitizeStatusValue('active')).toBe('available') + }) + + it('should resolve Active (uppercase) to available', () => { + expect(sanitizeStatusValue('Active')).toBe('available') + }) + + it('should resolve ACTIVE to available', () => { + expect(sanitizeStatusValue('ACTIVE')).toBe('available') + }) + }) + + // Unknown values should fall back to 'all' + describe('unknown values', () => { + it('should return all when value is unknown', () => { + expect(sanitizeStatusValue('unknown')).toBe('all') + }) + + it('should return all when value is an arbitrary string', () => { + expect(sanitizeStatusValue('foobar')).toBe('all') + }) + + it('should return all when value is a numeric string', () => { + expect(sanitizeStatusValue('123')).toBe('all') + }) + }) + }) + + // Tests for normalizeStatusForQuery + describe('normalizeStatusForQuery', () => { + // When sanitized value is 'all', should return 'all' + describe('all status', () => { + it('should return all when value is undefined', () => { + expect(normalizeStatusForQuery(undefined)).toBe('all') + }) + + it('should return all when value is null', () => { + expect(normalizeStatusForQuery(null)).toBe('all') + }) + + it('should return all when value is empty string', () => { + expect(normalizeStatusForQuery('')).toBe('all') + }) + + it('should return all when value is all', () => { + expect(normalizeStatusForQuery('all')).toBe('all') + }) + + it('should return all when value is unknown (sanitized to all)', () => { + expect(normalizeStatusForQuery('unknown')).toBe('all') + }) + }) + + // Query alias resolution: enabled -> available + describe('query aliases', () => { + it('should resolve enabled to available', () => { + expect(normalizeStatusForQuery('enabled')).toBe('available') + }) + + it('should resolve Enabled (mixed case) to available', () => { + expect(normalizeStatusForQuery('Enabled')).toBe('available') + }) + }) + + // Non-aliased known values should pass through + describe('non-aliased known values', () => { + it.each([ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'disabled', + 'archived', + ])('should return %s as-is when not aliased', (status) => { + expect(normalizeStatusForQuery(status)).toBe(status) + }) + }) + + // URL alias flows through sanitize first, then query alias + describe('combined alias resolution', () => { + it('should resolve active through URL alias to available', () => { + // active -> sanitizeStatusValue -> available -> no query alias for available -> available + expect(normalizeStatusForQuery('active')).toBe('available') + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/documents-header.spec.tsx b/web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/documents-header.spec.tsx rename to web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx index 922affa865..0289a79e2a 100644 --- a/web/app/components/datasets/documents/components/documents-header.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx @@ -2,7 +2,7 @@ import type { SortType } from '@/service/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DataSourceType } from '@/models/datasets' -import DocumentsHeader from './documents-header' +import DocumentsHeader from '../documents-header' // Mock the context hooks vi.mock('@/context/i18n', () => ({ diff --git a/web/app/components/datasets/documents/components/empty-element.spec.tsx b/web/app/components/datasets/documents/components/__tests__/empty-element.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/components/empty-element.spec.tsx rename to web/app/components/datasets/documents/components/__tests__/empty-element.spec.tsx index c79ed3d50c..533d7b625c 100644 --- a/web/app/components/datasets/documents/components/empty-element.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/empty-element.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import EmptyElement from './empty-element' +import EmptyElement from '../empty-element' describe('EmptyElement', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/components/icons.spec.tsx b/web/app/components/datasets/documents/components/__tests__/icons.spec.tsx similarity index 97% rename from web/app/components/datasets/documents/components/icons.spec.tsx rename to web/app/components/datasets/documents/components/__tests__/icons.spec.tsx index 4ef9b0e68f..25852b6d8c 100644 --- a/web/app/components/datasets/documents/components/icons.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/icons.spec.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { FolderPlusIcon, NotionIcon, ThreeDotsIcon } from './icons' +import { FolderPlusIcon, NotionIcon, ThreeDotsIcon } from '../icons' describe('Icons', () => { describe('FolderPlusIcon', () => { diff --git a/web/app/components/datasets/documents/components/operations.spec.tsx b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/components/operations.spec.tsx rename to web/app/components/datasets/documents/components/__tests__/operations.spec.tsx index 22c094a4a9..5aae8dda73 100644 --- a/web/app/components/datasets/documents/components/operations.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx @@ -1,15 +1,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import Operations from './operations' +import Operations from '../operations' -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -17,7 +9,6 @@ vi.mock('next/navigation', () => ({ }), })) -// Mock ToastContext const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ ToastContext: { @@ -120,7 +111,7 @@ describe('Operations', () => { it('should not render settings when embeddingAvailable is false', () => { render() - expect(screen.queryByText('list.action.settings')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.list.action.settings')).not.toBeInTheDocument() }) it('should render disabled switch when embeddingAvailable is false in list scene', () => { @@ -262,13 +253,13 @@ describe('Operations', () => { render() await openPopover() // Check if popover content is visible - expect(screen.getByText('list.table.rename')).toBeInTheDocument() + expect(screen.getByText('datasetDocuments.list.table.rename')).toBeInTheDocument() }) it('should call archive when archive action is clicked', async () => { render() await openPopover() - const archiveButton = screen.getByText('list.action.archive') + const archiveButton = screen.getByText('datasetDocuments.list.action.archive') await act(async () => { fireEvent.click(archiveButton) }) @@ -285,7 +276,7 @@ describe('Operations', () => { />, ) await openPopover() - const unarchiveButton = screen.getByText('list.action.unarchive') + const unarchiveButton = screen.getByText('datasetDocuments.list.action.unarchive') await act(async () => { fireEvent.click(unarchiveButton) }) @@ -297,23 +288,22 @@ describe('Operations', () => { it('should show delete confirmation modal when delete is clicked', async () => { render() await openPopover() - const deleteButton = screen.getByText('list.action.delete') + const deleteButton = screen.getByText('datasetDocuments.list.action.delete') await act(async () => { fireEvent.click(deleteButton) }) // Check if confirmation modal is shown - expect(screen.getByText('list.delete.title')).toBeInTheDocument() + expect(screen.getByText('datasetDocuments.list.delete.title')).toBeInTheDocument() }) it('should call delete when confirm is clicked in delete modal', async () => { render() await openPopover() - const deleteButton = screen.getByText('list.action.delete') + const deleteButton = screen.getByText('datasetDocuments.list.action.delete') await act(async () => { fireEvent.click(deleteButton) }) - // Click confirm button - const confirmButton = screen.getByText('operation.sure') + const confirmButton = screen.getByText('common.operation.sure') await act(async () => { fireEvent.click(confirmButton) }) @@ -325,20 +315,20 @@ describe('Operations', () => { it('should close delete modal when cancel is clicked', async () => { render() await openPopover() - const deleteButton = screen.getByText('list.action.delete') + const deleteButton = screen.getByText('datasetDocuments.list.action.delete') await act(async () => { fireEvent.click(deleteButton) }) // Verify modal is shown - expect(screen.getByText('list.delete.title')).toBeInTheDocument() - // Find and click the cancel button (text: operation.cancel) - const cancelButton = screen.getByText('operation.cancel') + expect(screen.getByText('datasetDocuments.list.delete.title')).toBeInTheDocument() + // Find and click the cancel button + const cancelButton = screen.getByText('common.operation.cancel') await act(async () => { fireEvent.click(cancelButton) }) // Modal should be closed - title shouldn't be visible await waitFor(() => { - expect(screen.queryByText('list.delete.title')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.list.delete.title')).not.toBeInTheDocument() }) }) @@ -351,11 +341,11 @@ describe('Operations', () => { />, ) await openPopover() - const deleteButton = screen.getByText('list.action.delete') + const deleteButton = screen.getByText('datasetDocuments.list.action.delete') await act(async () => { fireEvent.click(deleteButton) }) - const confirmButton = screen.getByText('operation.sure') + const confirmButton = screen.getByText('common.operation.sure') await act(async () => { fireEvent.click(confirmButton) }) @@ -367,7 +357,7 @@ describe('Operations', () => { it('should show rename modal when rename is clicked', async () => { render() await openPopover() - const renameButton = screen.getByText('list.table.rename') + const renameButton = screen.getByText('datasetDocuments.list.table.rename') await act(async () => { fireEvent.click(renameButton) }) @@ -385,7 +375,7 @@ describe('Operations', () => { />, ) await openPopover() - const syncButton = screen.getByText('list.action.sync') + const syncButton = screen.getByText('datasetDocuments.list.action.sync') await act(async () => { fireEvent.click(syncButton) }) @@ -402,7 +392,7 @@ describe('Operations', () => { />, ) await openPopover() - const syncButton = screen.getByText('list.action.sync') + const syncButton = screen.getByText('datasetDocuments.list.action.sync') await act(async () => { fireEvent.click(syncButton) }) @@ -419,7 +409,7 @@ describe('Operations', () => { />, ) await openPopover() - const pauseButton = screen.getByText('list.action.pause') + const pauseButton = screen.getByText('datasetDocuments.list.action.pause') await act(async () => { fireEvent.click(pauseButton) }) @@ -436,7 +426,7 @@ describe('Operations', () => { />, ) await openPopover() - const resumeButton = screen.getByText('list.action.resume') + const resumeButton = screen.getByText('datasetDocuments.list.action.resume') await act(async () => { fireEvent.click(resumeButton) }) @@ -448,7 +438,7 @@ describe('Operations', () => { it('should download file when download action is clicked', async () => { render() await openPopover() - const downloadButton = screen.getByText('list.action.download') + const downloadButton = screen.getByText('datasetDocuments.list.action.download') await act(async () => { fireEvent.click(downloadButton) }) @@ -466,7 +456,7 @@ describe('Operations', () => { />, ) await openPopover() - expect(screen.getByText('list.action.download')).toBeInTheDocument() + expect(screen.getByText('datasetDocuments.list.action.download')).toBeInTheDocument() }) it('should download archived file when download is clicked', async () => { @@ -477,7 +467,7 @@ describe('Operations', () => { />, ) await openPopover() - const downloadButton = screen.getByText('list.action.download') + const downloadButton = screen.getByText('datasetDocuments.list.action.download') await act(async () => { fireEvent.click(downloadButton) }) @@ -497,14 +487,14 @@ describe('Operations', () => { fireEvent.click(moreButton) }) } - const archiveButton = screen.getByText('list.action.archive') + const archiveButton = screen.getByText('datasetDocuments.list.action.archive') await act(async () => { fireEvent.click(archiveButton) }) await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'actionMsg.modifiedUnsuccessfully', + message: 'common.actionMsg.modifiedUnsuccessfully', }) }) }) @@ -518,14 +508,14 @@ describe('Operations', () => { fireEvent.click(moreButton) }) } - const downloadButton = screen.getByText('list.action.download') + const downloadButton = screen.getByText('datasetDocuments.list.action.download') await act(async () => { fireEvent.click(downloadButton) }) await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'actionMsg.downloadUnsuccessfully', + message: 'common.actionMsg.downloadUnsuccessfully', }) }) }) @@ -539,14 +529,14 @@ describe('Operations', () => { fireEvent.click(moreButton) }) } - const downloadButton = screen.getByText('list.action.download') + const downloadButton = screen.getByText('datasetDocuments.list.action.download') await act(async () => { fireEvent.click(downloadButton) }) await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'actionMsg.downloadUnsuccessfully', + message: 'common.actionMsg.downloadUnsuccessfully', }) }) }) @@ -586,8 +576,8 @@ describe('Operations', () => { fireEvent.click(moreButton) }) } - expect(screen.queryByText('list.action.pause')).not.toBeInTheDocument() - expect(screen.queryByText('list.action.resume')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.list.action.pause')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.list.action.resume')).not.toBeInTheDocument() }) }) @@ -625,7 +615,7 @@ describe('Operations', () => { fireEvent.click(moreButton) }) } - expect(screen.queryByText('list.action.download')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.list.action.download')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/components/rename-modal.spec.tsx b/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/rename-modal.spec.tsx rename to web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx index 4bacec6e9d..9ed61a66e0 100644 --- a/web/app/components/datasets/documents/components/rename-modal.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Import after mock import { renameDocumentName } from '@/service/datasets' -import RenameModal from './rename-modal' +import RenameModal from '../rename-modal' // Mock the service vi.mock('@/service/datasets', () => ({ diff --git a/web/app/components/datasets/documents/components/document-list/index.spec.tsx b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/document-list/index.spec.tsx rename to web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx index 32429cc0ac..5ea2a00a7d 100644 --- a/web/app/components/datasets/documents/components/document-list/index.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode, DataSourceType } from '@/models/datasets' -import DocumentList from '../list' +import DocumentList from '../../list' const mockPush = vi.fn() @@ -204,7 +204,6 @@ describe('DocumentList', () => { const props = { ...defaultProps, onSelectedIdChange } const { container } = render(, { wrapper: createWrapper() }) - // Click the second checkbox (first row checkbox) const checkboxes = findCheckboxes(container) if (checkboxes.length > 1) { fireEvent.click(checkboxes[1]) diff --git a/web/app/components/datasets/documents/components/document-list/components/document-source-icon.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-source-icon.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/document-list/components/document-source-icon.spec.tsx rename to web/app/components/datasets/documents/components/document-list/components/__tests__/document-source-icon.spec.tsx index 33108fbbac..2a42273a9b 100644 --- a/web/app/components/datasets/documents/components/document-list/components/document-source-icon.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-source-icon.spec.tsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { DataSourceType } from '@/models/datasets' import { DatasourceType } from '@/models/pipeline' -import DocumentSourceIcon from './document-source-icon' +import DocumentSourceIcon from '../document-source-icon' const createMockDoc = (overrides: Record = {}): SimpleDocumentDetail => ({ id: 'doc-1', diff --git a/web/app/components/datasets/documents/components/document-list/components/document-table-row.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/document-list/components/document-table-row.spec.tsx rename to web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx index 7157a9bf4b..ad920e9a37 100644 --- a/web/app/components/datasets/documents/components/document-list/components/document-table-row.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DataSourceType } from '@/models/datasets' -import DocumentTableRow from './document-table-row' +import DocumentTableRow from '../document-table-row' const mockPush = vi.fn() @@ -153,7 +153,6 @@ describe('DocumentTableRow', () => { it('should stop propagation when checkbox container is clicked', () => { const { container } = render(, { wrapper: createWrapper() }) - // Click the div containing the checkbox (which has stopPropagation) const checkboxContainer = container.querySelector('td')?.querySelector('div') if (checkboxContainer) { fireEvent.click(checkboxContainer) diff --git a/web/app/components/datasets/documents/components/document-list/components/sort-header.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/sort-header.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/components/document-list/components/sort-header.spec.tsx rename to web/app/components/datasets/documents/components/document-list/components/__tests__/sort-header.spec.tsx index 15cc55247b..777f240d00 100644 --- a/web/app/components/datasets/documents/components/document-list/components/sort-header.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/sort-header.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import SortHeader from './sort-header' +import SortHeader from '../sort-header' describe('SortHeader', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/components/document-list/components/utils.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/utils.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/components/document-list/components/utils.spec.tsx rename to web/app/components/datasets/documents/components/document-list/components/__tests__/utils.spec.tsx index 7dc66d4d39..51b6db9d63 100644 --- a/web/app/components/datasets/documents/components/document-list/components/utils.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/utils.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { renderTdValue } from './utils' +import { renderTdValue } from '../utils' describe('renderTdValue', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts new file mode 100644 index 0000000000..5f48be084e --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts @@ -0,0 +1,231 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DocumentActionType } from '@/models/datasets' +import { useDocumentActions } from '../use-document-actions' + +const mockArchive = vi.fn() +const mockSummary = vi.fn() +const mockEnable = vi.fn() +const mockDisable = vi.fn() +const mockDelete = vi.fn() +const mockRetryIndex = vi.fn() +const mockDownloadZip = vi.fn() +let mockIsDownloadingZip = false + +vi.mock('@/service/knowledge/use-document', () => ({ + useDocumentArchive: () => ({ mutateAsync: mockArchive }), + useDocumentSummary: () => ({ mutateAsync: mockSummary }), + useDocumentEnable: () => ({ mutateAsync: mockEnable }), + useDocumentDisable: () => ({ mutateAsync: mockDisable }), + useDocumentDelete: () => ({ mutateAsync: mockDelete }), + useDocumentBatchRetryIndex: () => ({ mutateAsync: mockRetryIndex }), + useDocumentDownloadZip: () => ({ mutateAsync: mockDownloadZip, isPending: mockIsDownloadingZip }), +})) + +const mockToastNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (...args: unknown[]) => mockToastNotify(...args) }, +})) + +const mockDownloadBlob = vi.fn() +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +describe('useDocumentActions', () => { + const defaultOptions = { + datasetId: 'ds-1', + selectedIds: ['doc-1', 'doc-2'], + downloadableSelectedIds: ['doc-1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockIsDownloadingZip = false + }) + + it('should return expected functions and state', () => { + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + expect(result.current.handleAction).toBeInstanceOf(Function) + expect(result.current.handleBatchReIndex).toBeInstanceOf(Function) + expect(result.current.handleBatchDownload).toBeInstanceOf(Function) + expect(typeof result.current.isDownloadingZip).toBe('boolean') + }) + + describe('handleAction', () => { + it('should call archive API and show success toast', async () => { + mockArchive.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.archive)() + }) + + expect(mockArchive).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentIds: ['doc-1', 'doc-2'], + }) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success' }), + ) + expect(defaultOptions.onUpdate).toHaveBeenCalled() + }) + + it('should call enable API on enable action', async () => { + mockEnable.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.enable)() + }) + + expect(mockEnable).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentIds: ['doc-1', 'doc-2'], + }) + expect(defaultOptions.onUpdate).toHaveBeenCalled() + }) + + it('should call disable API on disable action', async () => { + mockDisable.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.disable)() + }) + + expect(mockDisable).toHaveBeenCalled() + }) + + it('should call summary API on summary action', async () => { + mockSummary.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.summary)() + }) + + expect(mockSummary).toHaveBeenCalled() + }) + + it('should call onClearSelection on delete action success', async () => { + mockDelete.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.delete)() + }) + + expect(mockDelete).toHaveBeenCalled() + expect(defaultOptions.onClearSelection).toHaveBeenCalled() + expect(defaultOptions.onUpdate).toHaveBeenCalled() + }) + + it('should not call onClearSelection on non-delete action success', async () => { + mockArchive.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.archive)() + }) + + expect(defaultOptions.onClearSelection).not.toHaveBeenCalled() + }) + + it('should show error toast on action failure', async () => { + mockArchive.mockRejectedValue(new Error('fail')) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.archive)() + }) + + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + expect(defaultOptions.onUpdate).not.toHaveBeenCalled() + }) + }) + + describe('handleBatchReIndex', () => { + it('should call retry index API and show success toast', async () => { + mockRetryIndex.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleBatchReIndex() + }) + + expect(mockRetryIndex).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentIds: ['doc-1', 'doc-2'], + }) + expect(defaultOptions.onClearSelection).toHaveBeenCalled() + expect(defaultOptions.onUpdate).toHaveBeenCalled() + }) + + it('should show error toast on reindex failure', async () => { + mockRetryIndex.mockRejectedValue(new Error('fail')) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleBatchReIndex() + }) + + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) + + describe('handleBatchDownload', () => { + it('should download blob on success', async () => { + const blob = new Blob(['test']) + mockDownloadZip.mockResolvedValue(blob) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleBatchDownload() + }) + + expect(mockDownloadZip).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentIds: ['doc-1'], + }) + expect(mockDownloadBlob).toHaveBeenCalledWith( + expect.objectContaining({ + data: blob, + fileName: expect.stringContaining('-docs.zip'), + }), + ) + }) + + it('should show error toast on download failure', async () => { + mockDownloadZip.mockRejectedValue(new Error('fail')) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleBatchDownload() + }) + + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should show error toast when blob is null', async () => { + mockDownloadZip.mockResolvedValue(null) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleBatchDownload() + }) + + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.spec.tsx b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.spec.tsx rename to web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.tsx index bc84477744..4b537f95a3 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.tsx @@ -4,7 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DocumentActionType } from '@/models/datasets' import * as useDocument from '@/service/knowledge/use-document' -import { useDocumentActions } from './use-document-actions' +import { useDocumentActions } from '../use-document-actions' vi.mock('@/service/knowledge/use-document') diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-selection.spec.ts similarity index 99% rename from web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.spec.ts rename to web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-selection.spec.ts index 7775c83f1c..32e4ff88b4 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.spec.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-selection.spec.ts @@ -2,7 +2,7 @@ import type { SimpleDocumentDetail } from '@/models/datasets' import { act, renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { DataSourceType } from '@/models/datasets' -import { useDocumentSelection } from './use-document-selection' +import { useDocumentSelection } from '../use-document-selection' type LocalDoc = SimpleDocumentDetail & { percent?: number } diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-sort.spec.ts similarity index 99% rename from web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.spec.ts rename to web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-sort.spec.ts index a41b42d6fa..43bc0e1dd5 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.spec.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-sort.spec.ts @@ -1,7 +1,7 @@ import type { SimpleDocumentDetail } from '@/models/datasets' import { act, renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { useDocumentSort } from './use-document-sort' +import { useDocumentSort } from '../use-document-sort' type LocalDoc = SimpleDocumentDetail & { percent?: number } diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/datasets/documents/create-from-pipeline/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx index c43678def0..0096dc8c29 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx @@ -18,19 +18,17 @@ import { useOnlineDocument, useOnlineDrive, useWebsiteCrawl, -} from './hooks' -import { StepOneContent, StepThreeContent, StepTwoContent } from './steps' -import { StepOnePreview, StepTwoPreview } from './steps/preview-panel' +} from '../hooks' +import { StepOneContent, StepThreeContent, StepTwoContent } from '../steps' +import { StepOnePreview, StepTwoPreview } from '../steps/preview-panel' import { buildLocalFileDatasourceInfo, buildOnlineDocumentDatasourceInfo, buildOnlineDriveDatasourceInfo, buildWebsiteCrawlDatasourceInfo, -} from './utils/datasource-info-builder' +} from '../utils/datasource-info-builder' -// ========================================== // Mock External Dependencies Only -// ========================================== // Mock context providers const mockPlan = { @@ -92,7 +90,6 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -// Mock next/navigation vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ @@ -171,21 +168,17 @@ const mockStoreState = { bucket: '', } -vi.mock('./data-source/store', () => ({ +vi.mock('../data-source/store', () => ({ useDataSourceStore: () => ({ getState: () => mockStoreState, }), useDataSourceStoreWithSelector: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), })) -vi.mock('./data-source/store/provider', () => ({ +vi.mock('../data-source/store/provider', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}, })) -// ========================================== -// Test Data Factories -// ========================================== - const createMockDatasource = (overrides?: Partial): Datasource => ({ nodeId: 'node-1', nodeData: { @@ -242,9 +235,7 @@ const createMockOnlineDriveFile = (overrides?: Partial): Online ...overrides, } as OnlineDriveFile) -// ========================================== // Hook Tests - useAddDocumentsSteps -// ========================================== describe('useAddDocumentsSteps', () => { it('should initialize with step 1', () => { const { result } = renderHook(() => useAddDocumentsSteps()) @@ -292,9 +283,7 @@ describe('useAddDocumentsSteps', () => { }) }) -// ========================================== // Hook Tests - useDatasourceUIState -// ========================================== describe('useDatasourceUIState', () => { const defaultParams = { datasource: undefined as Datasource | undefined, @@ -475,9 +464,7 @@ describe('useDatasourceUIState', () => { }) }) -// ========================================== // Utility Functions Tests - datasource-info-builder -// ========================================== describe('datasource-info-builder', () => { describe('buildLocalFileDatasourceInfo', () => { it('should build correct info for local file', () => { @@ -556,9 +543,7 @@ describe('datasource-info-builder', () => { }) }) -// ========================================== // Step Components Tests (with real components) -// ========================================== describe('StepOneContent', () => { const defaultProps = { datasource: undefined as Datasource | undefined, @@ -639,7 +624,7 @@ describe('StepOneContent', () => { describe('StepTwoContent', () => { // Mock ProcessDocuments since it has complex dependencies - vi.mock('./process-documents', () => ({ + vi.mock('../process-documents', () => ({ default: React.forwardRef(({ dataSourceNodeId, isRunning, onProcess, onPreview, onSubmit, onBack }: { dataSourceNodeId: string isRunning: boolean @@ -713,7 +698,7 @@ describe('StepTwoContent', () => { describe('StepThreeContent', () => { // Mock Processing since it has complex dependencies - vi.mock('./processing', () => ({ + vi.mock('../processing', () => ({ default: ({ batchId, documents }: { batchId: string, documents: unknown[] }) => (
{batchId} @@ -739,12 +724,10 @@ describe('StepThreeContent', () => { }) }) -// ========================================== // Preview Panel Tests -// ========================================== describe('StepOnePreview', () => { // Mock preview components - vi.mock('./preview/file-preview', () => ({ + vi.mock('../preview/file-preview', () => ({ default: ({ file, hidePreview }: { file: CustomFile, hidePreview: () => void }) => (
{file.name} @@ -753,7 +736,7 @@ describe('StepOnePreview', () => { ), })) - vi.mock('./preview/online-document-preview', () => ({ + vi.mock('../preview/online-document-preview', () => ({ default: ({ datasourceNodeId, currentPage, hidePreview }: { datasourceNodeId: string currentPage: NotionPage & { workspace_id: string } @@ -767,7 +750,7 @@ describe('StepOnePreview', () => { ), })) - vi.mock('./preview/web-preview', () => ({ + vi.mock('../preview/web-preview', () => ({ default: ({ currentWebsite, hidePreview }: { currentWebsite: CrawlResultItem, hidePreview: () => void }) => (
{currentWebsite.source_url} @@ -847,7 +830,7 @@ describe('StepOnePreview', () => { describe('StepTwoPreview', () => { // Mock ChunkPreview - vi.mock('./preview/chunk-preview', () => ({ + vi.mock('../preview/chunk-preview', () => ({ default: ({ dataSourceType, isIdle, isPending, onPreview }: { dataSourceType: string isIdle: boolean @@ -913,9 +896,6 @@ describe('StepTwoPreview', () => { }) }) -// ========================================== -// Edge Cases Tests -// ========================================== describe('Edge Cases', () => { describe('Empty States', () => { it('should handle undefined datasource in useDatasourceUIState', () => { @@ -996,22 +976,20 @@ describe('Edge Cases', () => { }) }) -// ========================================== // Component Memoization Tests -// ========================================== describe('Component Memoization', () => { it('StepOneContent should be memoized', async () => { - const StepOneContentModule = await import('./steps/step-one-content') + const StepOneContentModule = await import('../steps/step-one-content') expect(StepOneContentModule.default.$$typeof).toBe(Symbol.for('react.memo')) }) it('StepTwoContent should be memoized', async () => { - const StepTwoContentModule = await import('./steps/step-two-content') + const StepTwoContentModule = await import('../steps/step-two-content') expect(StepTwoContentModule.default.$$typeof).toBe(Symbol.for('react.memo')) }) it('StepThreeContent should be memoized', async () => { - const StepThreeContentModule = await import('./steps/step-three-content') + const StepThreeContentModule = await import('../steps/step-three-content') expect(StepThreeContentModule.default.$$typeof).toBe(Symbol.for('react.memo')) }) @@ -1024,9 +1002,7 @@ describe('Component Memoization', () => { }) }) -// ========================================== // Hook Callback Stability Tests -// ========================================== describe('Hook Callback Stability', () => { describe('useDatasourceUIState memoization', () => { it('should maintain stable reference for datasourceType when dependencies unchanged', () => { @@ -1054,9 +1030,7 @@ describe('Hook Callback Stability', () => { }) }) -// ========================================== // Store Hooks Tests -// ========================================== describe('Store Hooks', () => { describe('useLocalFile', () => { it('should return localFileList from store', () => { @@ -1123,9 +1097,7 @@ describe('Store Hooks', () => { }) }) -// ========================================== // All Datasource Types Tests -// ========================================== describe('All Datasource Types', () => { const datasourceTypes = [ { type: DatasourceType.localFile, name: 'Local File' }, @@ -1161,9 +1133,7 @@ describe('All Datasource Types', () => { }) }) -// ========================================== // useDatasourceOptions Hook Tests -// ========================================== describe('useDatasourceOptions', () => { it('should return empty array when no pipeline nodes', () => { const { result } = renderHook(() => useDatasourceOptions([])) @@ -1231,9 +1201,7 @@ describe('useDatasourceOptions', () => { }) }) -// ========================================== // useDatasourceActions Hook Tests -// ========================================== describe('useDatasourceActions', () => { const createMockDataSourceStore = () => ({ getState: () => ({ @@ -1496,9 +1464,7 @@ describe('useDatasourceActions', () => { }) }) -// ========================================== // Store Hooks - Additional Coverage Tests -// ========================================== describe('Store Hooks - Callbacks', () => { beforeEach(() => { vi.clearAllMocks() @@ -1600,24 +1566,22 @@ describe('Store Hooks - Callbacks', () => { }) }) -// ========================================== // StepOneContent - All Datasource Types -// ========================================== describe('StepOneContent - All Datasource Types', () => { // Mock data source components - vi.mock('./data-source/local-file', () => ({ + vi.mock('../data-source/local-file', () => ({ default: () =>
Local File
, })) - vi.mock('./data-source/online-documents', () => ({ + vi.mock('../data-source/online-documents', () => ({ default: () =>
Online Documents
, })) - vi.mock('./data-source/website-crawl', () => ({ + vi.mock('../data-source/website-crawl', () => ({ default: () =>
Website Crawl
, })) - vi.mock('./data-source/online-drive', () => ({ + vi.mock('../data-source/online-drive', () => ({ default: () =>
Online Drive
, })) @@ -1699,9 +1663,7 @@ describe('StepOneContent - All Datasource Types', () => { }) }) -// ========================================== // StepTwoPreview - with localFileList -// ========================================== describe('StepTwoPreview - File List Mapping', () => { it('should correctly map localFileList to localFiles', () => { const fileList = [ @@ -1732,9 +1694,7 @@ describe('StepTwoPreview - File List Mapping', () => { }) }) -// ========================================== // useDatasourceActions - Additional Coverage -// ========================================== describe('useDatasourceActions - Async Functions', () => { beforeEach(() => { vi.clearAllMocks() @@ -2099,9 +2059,7 @@ describe('useDatasourceActions - Async Functions', () => { }) }) -// ========================================== // useDatasourceActions - onSuccess Callbacks -// ========================================== describe('useDatasourceActions - API Success Callbacks', () => { beforeEach(() => { vi.clearAllMocks() @@ -2257,9 +2215,7 @@ describe('useDatasourceActions - API Success Callbacks', () => { }) }) -// ========================================== // useDatasourceActions - buildProcessDatasourceInfo Coverage -// ========================================== describe('useDatasourceActions - Process Mode for All Datasource Types', () => { beforeEach(() => { vi.clearAllMocks() @@ -2544,9 +2500,7 @@ describe('useDatasourceActions - Process Mode for All Datasource Types', () => { }) }) -// ========================================== // useDatasourceActions - Edge Case Branches -// ========================================== describe('useDatasourceActions - Edge Case Branches', () => { beforeEach(() => { vi.clearAllMocks() @@ -2632,67 +2586,63 @@ describe('useDatasourceActions - Edge Case Branches', () => { }) }) -// ========================================== // Hooks Index Re-exports Test -// ========================================== describe('Hooks Index Re-exports', () => { it('should export useAddDocumentsSteps', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useAddDocumentsSteps).toBeDefined() }) it('should export useDatasourceActions', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useDatasourceActions).toBeDefined() }) it('should export useDatasourceOptions', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useDatasourceOptions).toBeDefined() }) it('should export useLocalFile', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useLocalFile).toBeDefined() }) it('should export useOnlineDocument', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useOnlineDocument).toBeDefined() }) it('should export useOnlineDrive', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useOnlineDrive).toBeDefined() }) it('should export useWebsiteCrawl', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useWebsiteCrawl).toBeDefined() }) it('should export useDatasourceUIState', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useDatasourceUIState).toBeDefined() }) }) -// ========================================== // Steps Index Re-exports Test -// ========================================== describe('Steps Index Re-exports', () => { it('should export StepOneContent', async () => { - const stepsModule = await import('./steps') + const stepsModule = await import('../steps') expect(stepsModule.StepOneContent).toBeDefined() }) it('should export StepTwoContent', async () => { - const stepsModule = await import('./steps') + const stepsModule = await import('../steps') expect(stepsModule.StepTwoContent).toBeDefined() }) it('should export StepThreeContent', async () => { - const stepsModule = await import('./steps') + const stepsModule = await import('../steps') expect(stepsModule.StepThreeContent).toBeDefined() }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx new file mode 100644 index 0000000000..584c21e826 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx @@ -0,0 +1,110 @@ +import type { Step } from '../step-indicator' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LeftHeader from '../left-header' + +vi.mock('next/navigation', () => ({ + useParams: () => ({ datasetId: 'test-ds-id' }), +})) + +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode, href: string }) => ( + {children} + ), +})) + +vi.mock('../step-indicator', () => ({ + default: ({ steps, currentStep }: { steps: Step[], currentStep: number }) => ( +
+ ), +})) + +vi.mock('@/app/components/base/effect', () => ({ + default: ({ className }: { className?: string }) => ( +
+ ), +})) + +const createSteps = (): Step[] => [ + { label: 'Data Source', value: 'data-source' }, + { label: 'Processing', value: 'processing' }, + { label: 'Complete', value: 'complete' }, +] + +describe('LeftHeader', () => { + const steps = createSteps() + + const defaultProps = { + steps, + title: 'Add Documents', + currentStep: 1, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: title, step label, and step indicator + describe('Rendering', () => { + it('should render title text', () => { + render() + + expect(screen.getByText('Add Documents')).toBeInTheDocument() + }) + + it('should render current step label (steps[currentStep-1].label)', () => { + render() + + expect(screen.getByText('Processing')).toBeInTheDocument() + }) + + it('should render step indicator component', () => { + render() + + expect(screen.getByTestId('step-indicator')).toBeInTheDocument() + }) + + it('should render separator between title and step indicator', () => { + render() + + expect(screen.getByText('/')).toBeInTheDocument() + }) + }) + + // Back button visibility depends on currentStep vs total steps + describe('Back Button', () => { + it('should show back button when currentStep !== steps.length', () => { + render() + + expect(screen.getByTestId('back-link')).toBeInTheDocument() + }) + + it('should hide back button when currentStep === steps.length', () => { + render() + + expect(screen.queryByTestId('back-link')).not.toBeInTheDocument() + }) + + it('should link to correct URL using datasetId from params', () => { + render() + + const link = screen.getByTestId('back-link') + expect(link).toHaveAttribute('href', '/datasets/test-ds-id/documents') + }) + }) + + // Edge case: step label for boundary values + describe('Edge Cases', () => { + it('should render first step label when currentStep is 1', () => { + render() + + expect(screen.getByText('Data Source')).toBeInTheDocument() + }) + + it('should render last step label when currentStep equals steps.length', () => { + render() + + expect(screen.getByText('Complete')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/step-indicator.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/step-indicator.spec.tsx new file mode 100644 index 0000000000..7103dced26 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/step-indicator.spec.tsx @@ -0,0 +1,32 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import StepIndicator from '../step-indicator' + +describe('StepIndicator', () => { + const steps = [ + { label: 'Data Source', value: 'data-source' }, + { label: 'Process', value: 'process' }, + { label: 'Embedding', value: 'embedding' }, + ] + + it('should render dots for each step', () => { + const { container } = render() + const dots = container.querySelectorAll('.rounded-lg') + expect(dots).toHaveLength(3) + }) + + it('should apply active style to current step', () => { + const { container } = render() + const dots = container.querySelectorAll('.rounded-lg') + // Second step (index 1) should be active + expect(dots[1].className).toContain('bg-state-accent-solid') + expect(dots[1].className).toContain('w-2') + }) + + it('should not apply active style to non-current steps', () => { + const { container } = render() + const dots = container.querySelectorAll('.rounded-lg') + expect(dots[1].className).toContain('bg-divider-solid') + expect(dots[2].className).toContain('bg-divider-solid') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx index cbb74bb796..45ecaa7e9b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx @@ -1,10 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import Actions from './index' - -// ========================================== -// Mock External Dependencies -// ========================================== +import Actions from '../index' // Mock next/navigation - useParams returns datasetId const mockDatasetId = 'test-dataset-id' @@ -21,10 +17,6 @@ vi.mock('next/link', () => ({ ), })) -// ========================================== -// Test Suite -// ========================================== - describe('Actions', () => { // Default mock for required props const defaultProps = { @@ -35,85 +27,63 @@ describe('Actions', () => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { // Tests basic rendering functionality it('should render without crashing', () => { - // Arrange & Act render() - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeInTheDocument() }) it('should render cancel button with correct link', () => { - // Arrange & Act render() - // Assert const cancelLink = screen.getByRole('link') expect(cancelLink).toHaveAttribute('href', `/datasets/${mockDatasetId}/documents`) expect(cancelLink).toHaveAttribute('data-replace', 'true') }) it('should render next step button with arrow icon', () => { - // Arrange & Act render() - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).toBeInTheDocument() expect(nextButton.querySelector('svg')).toBeInTheDocument() }) it('should render cancel button with correct translation key', () => { - // Arrange & Act render() - // Assert expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() }) it('should not render select all section by default', () => { - // Arrange & Act render() - // Assert expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { // Tests for prop variations and defaults describe('disabled prop', () => { it('should not disable next step button when disabled is false', () => { - // Arrange & Act render() - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).not.toBeDisabled() }) it('should disable next step button when disabled is true', () => { - // Arrange & Act render() - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).toBeDisabled() }) it('should not disable next step button when disabled is undefined', () => { - // Arrange & Act render() - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).not.toBeDisabled() }) @@ -121,66 +91,51 @@ describe('Actions', () => { describe('showSelect prop', () => { it('should show select all section when showSelect is true', () => { - // Arrange & Act render() - // Assert expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() }) it('should hide select all section when showSelect is false', () => { - // Arrange & Act render() - // Assert expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() }) it('should hide select all section when showSelect defaults to false', () => { - // Arrange & Act render() - // Assert expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() }) }) describe('tip prop', () => { it('should show tip when showSelect is true and tip is provided', () => { - // Arrange const tip = 'This is a helpful tip' - // Act render() - // Assert expect(screen.getByText(tip)).toBeInTheDocument() expect(screen.getByTitle(tip)).toBeInTheDocument() }) it('should not show tip when showSelect is false even if tip is provided', () => { - // Arrange const tip = 'This is a helpful tip' - // Act render() - // Assert expect(screen.queryByText(tip)).not.toBeInTheDocument() }) it('should not show tip when tip is empty string', () => { - // Arrange & Act render() - // Assert const tipElements = screen.queryAllByTitle('') // Empty tip should not render a tip element expect(tipElements.length).toBe(0) }) it('should use empty string as default tip value', () => { - // Arrange & Act render() // Assert - tip container should not exist when tip defaults to empty string @@ -190,37 +145,28 @@ describe('Actions', () => { }) }) - // ========================================== // Event Handlers Testing - // ========================================== describe('User Interactions', () => { // Tests for event handlers it('should call handleNextStep when next button is clicked', () => { - // Arrange const handleNextStep = vi.fn() render() - // Act fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - // Assert expect(handleNextStep).toHaveBeenCalledTimes(1) }) it('should not call handleNextStep when next button is disabled and clicked', () => { - // Arrange const handleNextStep = vi.fn() render() - // Act fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - // Assert expect(handleNextStep).not.toHaveBeenCalled() }) it('should call onSelectAll when checkbox is clicked', () => { - // Arrange const onSelectAll = vi.fn() render( { if (checkbox) fireEvent.click(checkbox) - // Assert expect(onSelectAll).toHaveBeenCalledTimes(1) }) }) - // ========================================== // Memoization Logic Testing - // ========================================== describe('Memoization Logic', () => { // Tests for useMemo hooks (indeterminate and checked) describe('indeterminate calculation', () => { it('should return false when showSelect is false', () => { - // Arrange & Act render( { }) it('should return false when selectedOptions is undefined', () => { - // Arrange & Act const { container } = render( { }) it('should return false when totalOptions is undefined', () => { - // Arrange & Act const { container } = render( { }) it('should return true when some options are selected (0 < selectedOptions < totalOptions)', () => { - // Arrange & Act const { container } = render( { }) it('should return false when no options are selected (selectedOptions === 0)', () => { - // Arrange & Act const { container } = render( { }) it('should return false when all options are selected (selectedOptions === totalOptions)', () => { - // Arrange & Act const { container } = render( { describe('checked calculation', () => { it('should return false when showSelect is false', () => { - // Arrange & Act render( { }) it('should return false when selectedOptions is undefined', () => { - // Arrange & Act const { container } = render( { />, ) - // Assert const checkbox = container.querySelector('[class*="cursor-pointer"]') expect(checkbox).toBeInTheDocument() }) it('should return false when totalOptions is undefined', () => { - // Arrange & Act const { container } = render( { />, ) - // Assert const checkbox = container.querySelector('[class*="cursor-pointer"]') expect(checkbox).toBeInTheDocument() }) it('should return true when all options are selected (selectedOptions === totalOptions)', () => { - // Arrange & Act const { container } = render( { }) it('should return false when selectedOptions is 0', () => { - // Arrange & Act const { container } = render( { }) it('should return false when not all options are selected', () => { - // Arrange & Act const { container } = render( { }) }) - // ========================================== // Component Memoization Testing - // ========================================== describe('Component Memoization', () => { // Tests for React.memo behavior it('should be wrapped with React.memo', () => { @@ -468,7 +395,6 @@ describe('Actions', () => { }) it('should not re-render when props are the same', () => { - // Arrange const handleNextStep = vi.fn() const props = { handleNextStep, @@ -480,7 +406,6 @@ describe('Actions', () => { tip: 'Test tip', } - // Act const { rerender } = render() // Re-render with same props @@ -492,7 +417,6 @@ describe('Actions', () => { }) it('should re-render when props change', () => { - // Arrange const handleNextStep = vi.fn() const initialProps = { handleNextStep, @@ -504,26 +428,21 @@ describe('Actions', () => { tip: 'Initial tip', } - // Act const { rerender } = render() expect(screen.getByText('Initial tip')).toBeInTheDocument() // Rerender with different props rerender() - // Assert expect(screen.getByText('Updated tip')).toBeInTheDocument() expect(screen.queryByText('Initial tip')).not.toBeInTheDocument() }) }) - // ========================================== // Edge Cases Testing - // ========================================== describe('Edge Cases', () => { // Tests for boundary conditions and unusual inputs it('should handle totalOptions of 0', () => { - // Arrange & Act const { container } = render( { }) it('should handle very large totalOptions', () => { - // Arrange & Act const { container } = render( { />, ) - // Assert const checkbox = container.querySelector('[class*="cursor-pointer"]') expect(checkbox).toBeInTheDocument() }) it('should handle very long tip text', () => { - // Arrange const longTip = 'A'.repeat(500) - // Act render( { }) it('should handle tip with special characters', () => { - // Arrange const specialTip = ' & "quotes" \'apostrophes\'' - // Act render( { }) it('should handle tip with unicode characters', () => { - // Arrange const unicodeTip = '选侭 5 äžȘæ–‡ä»¶ïŒŒć…± 10MB 🚀' - // Act render( { />, ) - // Assert expect(screen.getByText(unicodeTip)).toBeInTheDocument() }) it('should handle selectedOptions greater than totalOptions', () => { // This is an edge case that shouldn't happen but should be handled gracefully - // Arrange & Act const { container } = render( { }) it('should handle negative selectedOptions', () => { - // Arrange & Act const { container } = render( { }) it('should handle onSelectAll being undefined when showSelect is true', () => { - // Arrange & Act const { container } = render( { const checkbox = container.querySelector('[class*="cursor-pointer"]') expect(checkbox).toBeInTheDocument() - // Click should not throw if (checkbox) expect(() => fireEvent.click(checkbox)).not.toThrow() }) it('should handle empty datasetId from params', () => { // This test verifies the link is constructed even with empty datasetId - // Arrange & Act render() // Assert - link should still be present with the mocked datasetId @@ -678,23 +583,18 @@ describe('Actions', () => { }) }) - // ========================================== // All Prop Combinations Testing - // ========================================== describe('Prop Combinations', () => { // Tests for various combinations of props it('should handle disabled=true with showSelect=false', () => { - // Arrange & Act render() - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).toBeDisabled() expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() }) it('should handle disabled=true with showSelect=true', () => { - // Arrange & Act render( { />, ) - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).toBeDisabled() expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() }) it('should render complete component with all props provided', () => { - // Arrange const allProps = { disabled: false, handleNextStep: vi.fn(), @@ -724,10 +622,8 @@ describe('Actions', () => { tip: 'All props provided', } - // Act render() - // Assert expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() expect(screen.getByText('All props provided')).toBeInTheDocument() expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() @@ -735,19 +631,15 @@ describe('Actions', () => { }) it('should render minimal component with only required props', () => { - // Arrange & Act render() - // Assert expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) }) - // ========================================== // Selection State Variations Testing - // ========================================== describe('Selection State Variations', () => { // Tests for different selection states const selectionStates = [ @@ -763,7 +655,6 @@ describe('Actions', () => { it.each(selectionStates)( 'should render with $expectedState state when totalOptions=$totalOptions and selectedOptions=$selectedOptions', ({ totalOptions, selectedOptions }) => { - // Arrange & Act const { container } = render( { ) }) - // ========================================== // Layout Structure Testing - // ========================================== describe('Layout', () => { // Tests for correct layout structure it('should have correct container structure', () => { - // Arrange & Act const { container } = render() - // Assert const mainContainer = container.querySelector('.flex.items-center.gap-x-2.overflow-hidden') expect(mainContainer).toBeInTheDocument() }) it('should have correct button container structure', () => { - // Arrange & Act const { container } = render() // Assert - buttons should be in a flex container @@ -806,7 +692,6 @@ describe('Actions', () => { }) it('should position select all section before buttons when showSelect is true', () => { - // Arrange & Act const { container } = render( { + it('should render icon with background image', () => { + const { container } = render() + const iconDiv = container.querySelector('[style*="background-image"]') + expect(iconDiv).not.toBeNull() + expect(iconDiv?.getAttribute('style')).toContain('https://example.com/icon.png') + }) + + it('should apply size class for sm', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('w-5') + expect(wrapper.className).toContain('h-5') + }) + + it('should apply size class for md', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('w-6') + expect(wrapper.className).toContain('h-6') + }) + + it('should apply size class for xs', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('w-4') + expect(wrapper.className).toContain('h-4') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx new file mode 100644 index 0000000000..617da1f697 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx @@ -0,0 +1,141 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useDatasourceIcon } from '../hooks' + +const mockTransformDataSourceToTool = vi.fn() + +vi.mock('@/app/components/workflow/block-selector/utils', () => ({ + transformDataSourceToTool: (...args: unknown[]) => mockTransformDataSourceToTool(...args), +})) + +let mockDataSourceListReturn: { + data: Array<{ + plugin_id: string + provider: string + declaration: { identity: { icon: string, author: string } } + }> | undefined + isSuccess: boolean +} + +vi.mock('@/service/use-pipeline', () => ({ + useDataSourceList: () => mockDataSourceListReturn, +})) + +vi.mock('@/utils/var', () => ({ + basePath: '', +})) + +const createMockDataSourceNode = (overrides?: Partial): DataSourceNodeType => ({ + plugin_id: 'plugin-abc', + provider_type: 'builtin', + provider_name: 'web-scraper', + datasource_name: 'scraper', + datasource_label: 'Web Scraper', + datasource_parameters: {}, + datasource_configurations: {}, + title: 'DataSource', + desc: '', + type: '' as DataSourceNodeType['type'], + ...overrides, +} as DataSourceNodeType) + +describe('useDatasourceIcon', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDataSourceListReturn = { data: undefined, isSuccess: false } + mockTransformDataSourceToTool.mockReset() + }) + + // Returns undefined when data has not loaded + describe('Loading State', () => { + it('should return undefined when data is not loaded (isSuccess false)', () => { + mockDataSourceListReturn = { data: undefined, isSuccess: false } + + const { result } = renderHook(() => + useDatasourceIcon(createMockDataSourceNode()), + ) + + expect(result.current).toBeUndefined() + }) + }) + + // Returns correct icon when plugin_id matches + describe('Icon Resolution', () => { + it('should return correct icon when plugin_id matches', () => { + const mockIcon = 'https://example.com/icon.svg' + mockDataSourceListReturn = { + data: [ + { + plugin_id: 'plugin-abc', + provider: 'web-scraper', + declaration: { identity: { icon: mockIcon, author: 'dify' } }, + }, + ], + isSuccess: true, + } + mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({ + plugin_id: item.plugin_id, + icon: item.declaration.identity.icon, + })) + + const { result } = renderHook(() => + useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })), + ) + + expect(result.current).toBe(mockIcon) + }) + + it('should return undefined when plugin_id does not match', () => { + mockDataSourceListReturn = { + data: [ + { + plugin_id: 'plugin-xyz', + provider: 'other', + declaration: { identity: { icon: '/icon.svg', author: 'dify' } }, + }, + ], + isSuccess: true, + } + mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({ + plugin_id: item.plugin_id, + icon: item.declaration.identity.icon, + })) + + const { result } = renderHook(() => + useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })), + ) + + expect(result.current).toBeUndefined() + }) + }) + + // basePath prepending + describe('basePath Prepending', () => { + it('should prepend basePath to icon URL when not already included', () => { + // basePath is mocked as '' so prepending '' to '/icon.png' results in '/icon.png' + // The important thing is that the forEach logic runs without error + mockDataSourceListReturn = { + data: [ + { + plugin_id: 'plugin-abc', + provider: 'web-scraper', + declaration: { identity: { icon: '/icon.png', author: 'dify' } }, + }, + ], + isSuccess: true, + } + mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({ + plugin_id: item.plugin_id, + icon: item.declaration.identity.icon, + })) + + const { result } = renderHook(() => + useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })), + ) + + // With empty basePath, icon stays as '/icon.png' + expect(result.current).toBe('/icon.png') + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/index.spec.tsx index 57b73e9222..0ac2dfce20 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/index.spec.tsx @@ -5,18 +5,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' import * as React from 'react' import { BlockEnum } from '@/app/components/workflow/types' -import DatasourceIcon from './datasource-icon' -import { useDatasourceIcon } from './hooks' -import DataSourceOptions from './index' -import OptionCard from './option-card' - -// ========================================== -// Mock External Dependencies -// ========================================== +import DatasourceIcon from '../datasource-icon' +import { useDatasourceIcon } from '../hooks' +import DataSourceOptions from '../index' +import OptionCard from '../option-card' // Mock useDatasourceOptions hook from parent hooks const mockUseDatasourceOptions = vi.fn() -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useDatasourceOptions: (nodes: Node[]) => mockUseDatasourceOptions(nodes), })) @@ -37,10 +33,6 @@ vi.mock('@/utils/var', () => ({ basePath: '/mock-base-path', })) -// ========================================== -// Test Data Builders -// ========================================== - const createMockDataSourceNodeData = (overrides?: Partial): DataSourceNodeType => ({ title: 'Test Data Source', desc: 'Test description', @@ -99,10 +91,6 @@ const createMockDataSourceListItem = (overrides?: Record) => ({ ...overrides, }) -// ========================================== -// Test Utilities -// ========================================== - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -131,9 +119,7 @@ const createHookWrapper = () => { ) } -// ========================================== // DatasourceIcon Tests -// ========================================== describe('DatasourceIcon', () => { beforeEach(() => { vi.clearAllMocks() @@ -141,27 +127,21 @@ describe('DatasourceIcon', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render() - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render icon with background image', () => { - // Arrange const iconUrl = 'https://example.com/icon.png' - // Act const { container } = render() - // Assert const iconDiv = container.querySelector('[style*="background-image"]') expect(iconDiv).toHaveStyle({ backgroundImage: `url(${iconUrl})` }) }) it('should render with default size (sm)', () => { - // Arrange & Act const { container } = render() // Assert - Default size is 'sm' which maps to 'w-5 h-5' @@ -173,36 +153,30 @@ describe('DatasourceIcon', () => { describe('Props', () => { describe('size', () => { it('should render with xs size', () => { - // Arrange & Act const { container } = render( , ) - // Assert expect(container.firstChild).toHaveClass('w-4') expect(container.firstChild).toHaveClass('h-4') expect(container.firstChild).toHaveClass('rounded-[5px]') }) it('should render with sm size', () => { - // Arrange & Act const { container } = render( , ) - // Assert expect(container.firstChild).toHaveClass('w-5') expect(container.firstChild).toHaveClass('h-5') expect(container.firstChild).toHaveClass('rounded-md') }) it('should render with md size', () => { - // Arrange & Act const { container } = render( , ) - // Assert expect(container.firstChild).toHaveClass('w-6') expect(container.firstChild).toHaveClass('h-6') expect(container.firstChild).toHaveClass('rounded-lg') @@ -211,22 +185,18 @@ describe('DatasourceIcon', () => { describe('className', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( , ) - // Assert expect(container.firstChild).toHaveClass('custom-class') }) it('should merge custom className with default classes', () => { - // Arrange & Act const { container } = render( , ) - // Assert expect(container.firstChild).toHaveClass('custom-class') expect(container.firstChild).toHaveClass('w-5') expect(container.firstChild).toHaveClass('h-5') @@ -235,34 +205,26 @@ describe('DatasourceIcon', () => { describe('iconUrl', () => { it('should handle empty iconUrl', () => { - // Arrange & Act const { container } = render() - // Assert const iconDiv = container.querySelector('[style*="background-image"]') expect(iconDiv).toHaveStyle({ backgroundImage: 'url()' }) }) it('should handle special characters in iconUrl', () => { - // Arrange const iconUrl = 'https://example.com/icon.png?param=value&other=123' - // Act const { container } = render() - // Assert const iconDiv = container.querySelector('[style*="background-image"]') expect(iconDiv).toHaveStyle({ backgroundImage: `url(${iconUrl})` }) }) it('should handle data URL as iconUrl', () => { - // Arrange const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' - // Act const { container } = render() - // Assert const iconDiv = container.querySelector('[style*="background-image"]') expect(iconDiv).toBeInTheDocument() }) @@ -271,17 +233,14 @@ describe('DatasourceIcon', () => { describe('Styling', () => { it('should have flex container classes', () => { - // Arrange & Act const { container } = render() - // Assert expect(container.firstChild).toHaveClass('flex') expect(container.firstChild).toHaveClass('items-center') expect(container.firstChild).toHaveClass('justify-center') }) it('should have shadow-xs class from size map', () => { - // Arrange & Act const { container } = render() // Assert - Default size 'sm' has shadow-xs @@ -289,10 +248,8 @@ describe('DatasourceIcon', () => { }) it('should have inner div with bg-cover class', () => { - // Arrange & Act const { container } = render() - // Assert const innerDiv = container.querySelector('.bg-cover') expect(innerDiv).toBeInTheDocument() expect(innerDiv).toHaveClass('bg-center') @@ -301,9 +258,7 @@ describe('DatasourceIcon', () => { }) }) -// ========================================== // useDatasourceIcon Hook Tests -// ========================================== describe('useDatasourceIcon', () => { beforeEach(() => { vi.clearAllMocks() @@ -319,39 +274,32 @@ describe('useDatasourceIcon', () => { describe('Loading State', () => { it('should return undefined when data is not loaded', () => { - // Arrange mockUseDataSourceList.mockReturnValue({ data: undefined, isSuccess: false, }) const nodeData = createMockDataSourceNodeData() - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) - // Assert expect(result.current).toBeUndefined() }) it('should call useDataSourceList with true', () => { - // Arrange const nodeData = createMockDataSourceNodeData() - // Act renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) - // Assert expect(mockUseDataSourceList).toHaveBeenCalledWith(true) }) }) describe('Success State', () => { it('should return icon when data is loaded and plugin matches', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'test-plugin-id', @@ -374,7 +322,6 @@ describe('useDatasourceIcon', () => { })) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) @@ -384,7 +331,6 @@ describe('useDatasourceIcon', () => { }) it('should return undefined when plugin does not match', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'other-plugin-id', @@ -396,17 +342,14 @@ describe('useDatasourceIcon', () => { }) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) - // Assert expect(result.current).toBeUndefined() }) it('should prepend basePath to icon when icon does not include basePath', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'test-plugin-id', @@ -429,7 +372,6 @@ describe('useDatasourceIcon', () => { })) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) @@ -439,7 +381,6 @@ describe('useDatasourceIcon', () => { }) it('should not prepend basePath when icon already includes basePath', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'test-plugin-id', @@ -462,7 +403,6 @@ describe('useDatasourceIcon', () => { })) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) @@ -474,41 +414,34 @@ describe('useDatasourceIcon', () => { describe('Edge Cases', () => { it('should handle empty dataSourceList', () => { - // Arrange mockUseDataSourceList.mockReturnValue({ data: [], isSuccess: true, }) const nodeData = createMockDataSourceNodeData() - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) - // Assert expect(result.current).toBeUndefined() }) it('should handle null dataSourceList', () => { - // Arrange mockUseDataSourceList.mockReturnValue({ data: null, isSuccess: true, }) const nodeData = createMockDataSourceNodeData() - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) - // Assert expect(result.current).toBeUndefined() }) it('should handle icon as non-string type', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'test-plugin-id', @@ -531,7 +464,6 @@ describe('useDatasourceIcon', () => { })) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) @@ -541,7 +473,6 @@ describe('useDatasourceIcon', () => { }) it('should memoize result based on plugin_id', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'test-plugin-id', @@ -553,7 +484,6 @@ describe('useDatasourceIcon', () => { }) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result, rerender } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) @@ -568,9 +498,7 @@ describe('useDatasourceIcon', () => { }) }) -// ========================================== // OptionCard Tests -// ========================================== describe('OptionCard', () => { const defaultProps = { label: 'Test Option', @@ -589,23 +517,18 @@ describe('OptionCard', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderWithProviders() - // Assert expect(screen.getByText('Test Option')).toBeInTheDocument() }) it('should render label text', () => { - // Arrange & Act renderWithProviders() - // Assert expect(screen.getByText('Custom Label')).toBeInTheDocument() }) it('should render DatasourceIcon component', () => { - // Arrange & Act const { container } = renderWithProviders() // Assert - DatasourceIcon container should exist @@ -614,13 +537,10 @@ describe('OptionCard', () => { }) it('should set title attribute for label truncation', () => { - // Arrange const longLabel = 'This is a very long label that might be truncated' - // Act renderWithProviders() - // Assert const labelElement = screen.getByText(longLabel) expect(labelElement).toHaveAttribute('title', longLabel) }) @@ -629,43 +549,35 @@ describe('OptionCard', () => { describe('Props', () => { describe('selected', () => { it('should apply selected styles when selected is true', () => { - // Arrange & Act const { container } = renderWithProviders( , ) - // Assert const card = container.firstChild expect(card).toHaveClass('border-components-option-card-option-selected-border') expect(card).toHaveClass('bg-components-option-card-option-selected-bg') }) it('should apply unselected styles when selected is false', () => { - // Arrange & Act const { container } = renderWithProviders( , ) - // Assert const card = container.firstChild expect(card).toHaveClass('border-components-option-card-option-border') expect(card).toHaveClass('bg-components-option-card-option-bg') }) it('should apply text-text-primary to label when selected', () => { - // Arrange & Act renderWithProviders() - // Assert const label = screen.getByText('Test Option') expect(label).toHaveClass('text-text-primary') }) it('should apply text-text-secondary to label when not selected', () => { - // Arrange & Act renderWithProviders() - // Assert const label = screen.getByText('Test Option') expect(label).toHaveClass('text-text-secondary') }) @@ -673,7 +585,6 @@ describe('OptionCard', () => { describe('onClick', () => { it('should call onClick when card is clicked', () => { - // Arrange const mockOnClick = vi.fn() renderWithProviders( , @@ -685,12 +596,10 @@ describe('OptionCard', () => { expect(card).toBeInTheDocument() fireEvent.click(card!) - // Assert expect(mockOnClick).toHaveBeenCalledTimes(1) }) it('should not crash when onClick is not provided', () => { - // Arrange & Act renderWithProviders( , ) @@ -708,10 +617,8 @@ describe('OptionCard', () => { describe('nodeData', () => { it('should pass nodeData to useDatasourceIcon hook', () => { - // Arrange const customNodeData = createMockDataSourceNodeData({ plugin_id: 'custom-plugin' }) - // Act renderWithProviders() // Assert - Hook should be called (via useDataSourceList mock) @@ -722,45 +629,35 @@ describe('OptionCard', () => { describe('Styling', () => { it('should have cursor-pointer class', () => { - // Arrange & Act const { container } = renderWithProviders() - // Assert expect(container.firstChild).toHaveClass('cursor-pointer') }) it('should have flex layout classes', () => { - // Arrange & Act const { container } = renderWithProviders() - // Assert expect(container.firstChild).toHaveClass('flex') expect(container.firstChild).toHaveClass('items-center') expect(container.firstChild).toHaveClass('gap-2') }) it('should have rounded-xl border', () => { - // Arrange & Act const { container } = renderWithProviders() - // Assert expect(container.firstChild).toHaveClass('rounded-xl') expect(container.firstChild).toHaveClass('border') }) it('should have padding p-3', () => { - // Arrange & Act const { container } = renderWithProviders() - // Assert expect(container.firstChild).toHaveClass('p-3') }) it('should have line-clamp-2 for label truncation', () => { - // Arrange & Act renderWithProviders() - // Assert const label = screen.getByText('Test Option') expect(label).toHaveClass('line-clamp-2') }) @@ -777,9 +674,7 @@ describe('OptionCard', () => { }) }) -// ========================================== // DataSourceOptions Tests -// ========================================== describe('DataSourceOptions', () => { const defaultNodes = createMockPipelineNodes(3) const defaultOptions = defaultNodes.map(createMockDatasourceOption) @@ -799,35 +694,26 @@ describe('DataSourceOptions', () => { }) }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderWithProviders() - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() expect(screen.getByText('Data Source 2')).toBeInTheDocument() expect(screen.getByText('Data Source 3')).toBeInTheDocument() }) it('should render correct number of option cards', () => { - // Arrange & Act renderWithProviders() - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() expect(screen.getByText('Data Source 2')).toBeInTheDocument() expect(screen.getByText('Data Source 3')).toBeInTheDocument() }) it('should render with grid layout', () => { - // Arrange & Act const { container } = renderWithProviders() - // Assert const gridContainer = container.firstChild expect(gridContainer).toHaveClass('grid') expect(gridContainer).toHaveClass('w-full') @@ -836,68 +722,53 @@ describe('DataSourceOptions', () => { }) it('should render no option cards when options is empty', () => { - // Arrange mockUseDatasourceOptions.mockReturnValue([]) - // Act const { container } = renderWithProviders() - // Assert expect(screen.queryByText('Data Source')).not.toBeInTheDocument() // Grid container should still exist expect(container.firstChild).toHaveClass('grid') }) it('should render single option card when only one option exists', () => { - // Arrange const singleOption = [createMockDatasourceOption(defaultNodes[0])] mockUseDatasourceOptions.mockReturnValue(singleOption) - // Act renderWithProviders() - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() expect(screen.queryByText('Data Source 2')).not.toBeInTheDocument() }) }) - // ========================================== // Props Tests - // ========================================== describe('Props', () => { describe('pipelineNodes', () => { it('should pass pipelineNodes to useDatasourceOptions hook', () => { - // Arrange const customNodes = createMockPipelineNodes(2) mockUseDatasourceOptions.mockReturnValue(customNodes.map(createMockDatasourceOption)) - // Act renderWithProviders( , ) - // Assert expect(mockUseDatasourceOptions).toHaveBeenCalledWith(customNodes) }) it('should handle empty pipelineNodes array', () => { - // Arrange mockUseDatasourceOptions.mockReturnValue([]) - // Act renderWithProviders( , ) - // Assert expect(mockUseDatasourceOptions).toHaveBeenCalledWith([]) }) }) describe('datasourceNodeId', () => { it('should mark corresponding option as selected', () => { - // Arrange & Act const { container } = renderWithProviders( { }) it('should show no selection when datasourceNodeId is empty', () => { - // Arrange & Act const { container } = renderWithProviders( { }) it('should show no selection when datasourceNodeId does not match any option', () => { - // Arrange & Act const { container } = renderWithProviders( { />, ) - // Assert const selectedCards = container.querySelectorAll('.border-components-option-card-option-selected-border') expect(selectedCards).toHaveLength(0) }) it('should update selection when datasourceNodeId changes', () => { - // Arrange const { container, rerender } = renderWithProviders( { describe('onSelect', () => { it('should receive onSelect callback', () => { - // Arrange const mockOnSelect = vi.fn() - // Act renderWithProviders( { }) }) - // ========================================== // Side Effects and Cleanup Tests - // ========================================== describe('Side Effects and Cleanup', () => { describe('useEffect - Auto-select first option', () => { it('should auto-select first option when options exist and no datasourceNodeId', () => { - // Arrange const mockOnSelect = vi.fn() - // Act renderWithProviders( { }) it('should NOT auto-select when datasourceNodeId is provided', () => { - // Arrange const mockOnSelect = vi.fn() - // Act renderWithProviders( { }) it('should NOT auto-select when options array is empty', () => { - // Arrange mockUseDatasourceOptions.mockReturnValue([]) const mockOnSelect = vi.fn() - // Act renderWithProviders( { />, ) - // Assert expect(mockOnSelect).not.toHaveBeenCalled() }) it('should only run useEffect once on initial mount', () => { - // Arrange const mockOnSelect = vi.fn() const { rerender } = renderWithProviders( { }) }) - // ========================================== // Callback Stability and Memoization Tests - // ========================================== describe('Callback Stability and Memoization', () => { it('should maintain callback reference stability across renders with same props', () => { - // Arrange const mockOnSelect = vi.fn() const { rerender } = renderWithProviders( @@ -1118,7 +970,6 @@ describe('DataSourceOptions', () => { }) it('should update callback when onSelect changes', () => { - // Arrange const mockOnSelect1 = vi.fn() const mockOnSelect2 = vi.fn() @@ -1157,7 +1008,6 @@ describe('DataSourceOptions', () => { }) it('should update callback when options change', () => { - // Arrange const mockOnSelect = vi.fn() const { rerender } = renderWithProviders( @@ -1201,13 +1051,10 @@ describe('DataSourceOptions', () => { }) }) - // ========================================== // User Interactions and Event Handlers Tests - // ========================================== describe('User Interactions and Event Handlers', () => { describe('Option Selection', () => { it('should call onSelect with correct datasource when clicking an option', () => { - // Arrange const mockOnSelect = vi.fn() renderWithProviders( { // Act - Click second option fireEvent.click(screen.getByText('Data Source 2')) - // Assert expect(mockOnSelect).toHaveBeenCalledTimes(1) expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'node-2', @@ -1229,7 +1075,6 @@ describe('DataSourceOptions', () => { }) it('should allow selecting already selected option', () => { - // Arrange const mockOnSelect = vi.fn() renderWithProviders( { // Act - Click already selected option fireEvent.click(screen.getByText('Data Source 1')) - // Assert expect(mockOnSelect).toHaveBeenCalledTimes(1) expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'node-1', @@ -1251,7 +1095,6 @@ describe('DataSourceOptions', () => { }) it('should allow multiple sequential selections', () => { - // Arrange const mockOnSelect = vi.fn() renderWithProviders( { fireEvent.click(screen.getByText('Data Source 2')) fireEvent.click(screen.getByText('Data Source 3')) - // Assert expect(mockOnSelect).toHaveBeenCalledTimes(3) expect(mockOnSelect).toHaveBeenNthCalledWith(1, { nodeId: 'node-1', @@ -1285,7 +1127,6 @@ describe('DataSourceOptions', () => { describe('handelSelect Internal Logic', () => { it('should handle rapid successive clicks', async () => { - // Arrange const mockOnSelect = vi.fn() renderWithProviders( { }) }) - // ========================================== // Edge Cases and Error Handling Tests - // ========================================== describe('Edge Cases and Error Handling', () => { describe('Empty States', () => { it('should handle empty options array gracefully', () => { - // Arrange mockUseDatasourceOptions.mockReturnValue([]) - // Act const { container } = renderWithProviders( { />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should not crash when datasourceNodeId is undefined', () => { - // Arrange & Act renderWithProviders( { />, ) - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() }) }) describe('Null/Undefined Values', () => { it('should handle option with missing data properties', () => { - // Arrange const optionWithMinimalData = [{ label: 'Minimal Option', value: 'minimal-1', @@ -1367,22 +1200,18 @@ describe('DataSourceOptions', () => { }] mockUseDatasourceOptions.mockReturnValue(optionWithMinimalData) - // Act renderWithProviders() - // Assert expect(screen.getByText('Minimal Option')).toBeInTheDocument() }) }) describe('Large Data Sets', () => { it('should handle large number of options', () => { - // Arrange const manyNodes = createMockPipelineNodes(50) const manyOptions = manyNodes.map(createMockDatasourceOption) mockUseDatasourceOptions.mockReturnValue(manyOptions) - // Act renderWithProviders( { />, ) - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() expect(screen.getByText('Data Source 50')).toBeInTheDocument() }) @@ -1398,7 +1226,6 @@ describe('DataSourceOptions', () => { describe('Special Characters in Data', () => { it('should handle special characters in option labels', () => { - // Arrange const specialNode = createMockPipelineNode({ id: 'special-node', data: createMockDataSourceNodeData({ @@ -1408,7 +1235,6 @@ describe('DataSourceOptions', () => { const specialOptions = [createMockDatasourceOption(specialNode)] mockUseDatasourceOptions.mockReturnValue(specialOptions) - // Act renderWithProviders( { }) it('should handle unicode characters in option labels', () => { - // Arrange const unicodeNode = createMockPipelineNode({ id: 'unicode-node', data: createMockDataSourceNodeData({ @@ -1431,7 +1256,6 @@ describe('DataSourceOptions', () => { const unicodeOptions = [createMockDatasourceOption(unicodeNode)] mockUseDatasourceOptions.mockReturnValue(unicodeOptions) - // Act renderWithProviders( { />, ) - // Assert expect(screen.getByText('æ•°æźæș 📁 Source Ă©moji')).toBeInTheDocument() }) it('should handle empty string as option value', () => { - // Arrange const emptyValueOption = [{ label: 'Empty Value Option', value: '', @@ -1452,22 +1274,18 @@ describe('DataSourceOptions', () => { }] mockUseDatasourceOptions.mockReturnValue(emptyValueOption) - // Act renderWithProviders() - // Assert expect(screen.getByText('Empty Value Option')).toBeInTheDocument() }) }) describe('Boundary Conditions', () => { it('should handle single option selection correctly', () => { - // Arrange const singleOption = [createMockDatasourceOption(defaultNodes[0])] mockUseDatasourceOptions.mockReturnValue(singleOption) const mockOnSelect = vi.fn() - // Act renderWithProviders( { }) it('should handle options with same labels but different values', () => { - // Arrange const duplicateLabelOptions = [ { label: 'Duplicate Label', @@ -1498,7 +1315,6 @@ describe('DataSourceOptions', () => { mockUseDatasourceOptions.mockReturnValue(duplicateLabelOptions) const mockOnSelect = vi.fn() - // Act renderWithProviders( { const labels = screen.getAllByText('Duplicate Label') expect(labels).toHaveLength(2) - // Click second one fireEvent.click(labels[1]) expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'node-b', @@ -1522,7 +1337,6 @@ describe('DataSourceOptions', () => { describe('Component Unmounting', () => { it('should handle unmounting without errors', () => { - // Arrange const mockOnSelect = vi.fn() const { unmount } = renderWithProviders( { />, ) - // Act unmount() // Assert - No errors thrown, component cleanly unmounted @@ -1539,7 +1352,6 @@ describe('DataSourceOptions', () => { }) it('should handle unmounting during rapid interactions', async () => { - // Arrange const mockOnSelect = vi.fn() const { unmount } = renderWithProviders( { }) }) - // ========================================== - // Integration Tests - // ========================================== describe('Integration', () => { it('should render OptionCard with correct props', () => { - // Arrange & Act const { container } = renderWithProviders() // Assert - Verify real OptionCard components are rendered @@ -1575,7 +1383,6 @@ describe('DataSourceOptions', () => { }) it('should correctly pass selected state to OptionCard', () => { - // Arrange & Act const { container } = renderWithProviders( { />, ) - // Assert const cards = container.querySelectorAll('.rounded-xl.border') expect(cards[0]).not.toHaveClass('border-components-option-card-option-selected-border') expect(cards[1]).toHaveClass('border-components-option-card-option-selected-border') @@ -1592,7 +1398,6 @@ describe('DataSourceOptions', () => { it('should use option.value as key for React rendering', () => { // This test verifies that React doesn't throw duplicate key warnings - // Arrange const uniqueValueOptions = createMockPipelineNodes(5).map(createMockDatasourceOption) mockUseDatasourceOptions.mockReturnValue(uniqueValueOptions) @@ -1600,7 +1405,6 @@ describe('DataSourceOptions', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn()) renderWithProviders() - // Assert expect(consoleSpy).not.toHaveBeenCalledWith( expect.stringContaining('key'), ) @@ -1608,9 +1412,6 @@ describe('DataSourceOptions', () => { }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('All Prop Variations', () => { it.each([ { datasourceNodeId: '', description: 'empty string' }, @@ -1619,7 +1420,6 @@ describe('DataSourceOptions', () => { { datasourceNodeId: 'node-3', description: 'last node' }, { datasourceNodeId: 'non-existent', description: 'non-existent node' }, ])('should handle datasourceNodeId as $description', ({ datasourceNodeId }) => { - // Arrange & Act renderWithProviders( { />, ) - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() }) @@ -1637,12 +1436,10 @@ describe('DataSourceOptions', () => { { count: 3, description: 'few options' }, { count: 10, description: 'many options' }, ])('should render correctly with $description', ({ count }) => { - // Arrange const nodes = createMockPipelineNodes(count) const options = nodes.map(createMockDatasourceOption) mockUseDatasourceOptions.mockReturnValue(options) - // Act renderWithProviders( { />, ) - // Assert if (count > 0) expect(screen.getByText('Data Source 1')).toBeInTheDocument() else diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx new file mode 100644 index 0000000000..8f05b2671b --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx @@ -0,0 +1,110 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import OptionCard from '../option-card' + +const TEST_ICON_URL = 'https://example.com/test-icon.png' + +vi.mock('../hooks', () => ({ + useDatasourceIcon: () => TEST_ICON_URL, +})) + +vi.mock('../datasource-icon', () => ({ + default: ({ iconUrl }: { iconUrl: string }) => ( + datasource + ), +})) + +const createMockNodeData = (overrides: Partial = {}): DataSourceNodeType => ({ + title: 'Test Node', + desc: '', + type: {} as DataSourceNodeType['type'], + plugin_id: 'test-plugin', + provider_type: 'builtin', + provider_name: 'test-provider', + datasource_name: 'test-ds', + datasource_label: 'Test DS', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, +} as DataSourceNodeType) + +describe('OptionCard', () => { + const defaultProps = { + label: 'Google Drive', + selected: false, + nodeData: createMockNodeData(), + onClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: label text and icon + describe('Rendering', () => { + it('should render label text', () => { + render() + + expect(screen.getByText('Google Drive')).toBeInTheDocument() + }) + + it('should render datasource icon with correct URL', () => { + render() + + const icon = screen.getByTestId('datasource-icon') + expect(icon).toHaveAttribute('src', TEST_ICON_URL) + }) + + it('should set title attribute on label element', () => { + render() + + expect(screen.getByTitle('Google Drive')).toBeInTheDocument() + }) + }) + + // User interactions: clicking the card + describe('User Interactions', () => { + it('should call onClick when clicked', () => { + render() + + fireEvent.click(screen.getByText('Google Drive')) + + expect(defaultProps.onClick).toHaveBeenCalledOnce() + }) + + it('should not throw when onClick is undefined', () => { + expect(() => { + const { container } = render( + , + ) + fireEvent.click(container.firstElementChild!) + }).not.toThrow() + }) + }) + + // Props: selected state applies different styles + describe('Props', () => { + it('should apply selected styles when selected is true', () => { + const { container } = render() + + const card = container.firstElementChild + expect(card?.className).toContain('border-components-option-card-option-selected-border') + expect(card?.className).toContain('bg-components-option-card-option-selected-bg') + }) + + it('should apply default styles when selected is false', () => { + const { container } = render() + + const card = container.firstElementChild + expect(card?.className).not.toContain('border-components-option-card-option-selected-border') + }) + + it('should apply text-text-primary class to label when selected', () => { + render() + + const labelEl = screen.getByTitle('Google Drive') + expect(labelEl.className).toContain('text-text-primary') + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx new file mode 100644 index 0000000000..48a0615bcc --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Header from '../header' + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children }: { children: React.ReactNode }) => , +})) + +vi.mock('@/app/components/base/divider', () => ({ + default: () => , +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('../credential-selector', () => ({ + default: () =>
, +})) + +describe('Header', () => { + const defaultProps = { + docTitle: 'Documentation', + docLink: 'https://docs.example.com', + onClickConfiguration: vi.fn(), + pluginName: 'TestPlugin', + credentials: [], + currentCredentialId: '', + onCredentialChange: vi.fn(), + } + + it('should render doc link with title', () => { + render(
) + expect(screen.getByText('Documentation')).toBeInTheDocument() + }) + + it('should render credential selector', () => { + render(
) + expect(screen.getByTestId('credential-selector')).toBeInTheDocument() + }) + + it('should link to external doc', () => { + render(
) + const link = screen.getByText('Documentation').closest('a') + expect(link).toHaveAttribute('href', 'https://docs.example.com') + expect(link).toHaveAttribute('target', '_blank') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx index da5075ec8a..d595a50fe1 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { CredentialSelectorProps } from './index' +import type { CredentialSelectorProps } from '../index' import type { DataSourceCredential } from '@/types/pipeline' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import * as React from 'react' -import CredentialSelector from './index' +import CredentialSelector from '../index' // Mock CredentialTypeEnum to avoid deep import chain issues enum MockCredentialTypeEnum { @@ -20,26 +20,25 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({ // Mock portal-to-follow-elem - use React state to properly handle open/close vi.mock('@/app/components/base/portal-to-follow-elem', () => { - const MockPortalToFollowElem = ({ children, open }: any) => { + const MockPortalToFollowElem = ({ children, open }: { children: React.ReactNode, open: boolean }) => { return (
- {React.Children.map(children, (child: any) => { - if (!child) + {React.Children.map(children, (child) => { + if (!React.isValidElement(child)) return null - // Pass open state to children via context-like prop cloning - return React.cloneElement(child, { __portalOpen: open }) + return React.cloneElement(child as React.ReactElement<{ __portalOpen?: boolean }>, { __portalOpen: open }) })}
) } - const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => ( + const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: { children: React.ReactNode, onClick?: React.MouseEventHandler, className?: string, __portalOpen?: boolean }) => (
{children}
) - const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => { + const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: { children: React.ReactNode, className?: string, __portalOpen?: boolean }) => { // Match actual behavior: returns null when not open if (!__portalOpen) return null @@ -60,9 +59,6 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => { // CredentialIcon - imported directly (not mocked) // This is a simple UI component with no external dependencies -// ========================================== -// Test Data Builders -// ========================================== const createMockCredential = (overrides?: Partial): DataSourceCredential => ({ id: 'cred-1', name: 'Test Credential', @@ -94,38 +90,28 @@ describe('CredentialSelector', () => { vi.clearAllMocks() }) - // ========================================== // Rendering Tests - Verify component renders correctly - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('portal-root')).toBeInTheDocument() expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() }) it('should render current credential name in trigger', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('Credential 1')).toBeInTheDocument() }) it('should render credential icon with correct props', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() // Assert - CredentialIcon renders an img when avatarUrl is provided @@ -135,30 +121,23 @@ describe('CredentialSelector', () => { }) it('should render dropdown arrow icon', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() - // Assert const svgIcon = container.querySelector('svg') expect(svgIcon).toBeInTheDocument() }) it('should not render dropdown content initially', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() }) it('should render all credentials in dropdown when opened', () => { - // Arrange const props = createDefaultProps() render() @@ -173,41 +152,30 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Props Testing - Verify all prop variations - // ========================================== describe('Props', () => { describe('currentCredentialId prop', () => { it('should display first credential when currentCredentialId matches first', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-1' }) - // Act render() - // Assert expect(screen.getByText('Credential 1')).toBeInTheDocument() }) it('should display second credential when currentCredentialId matches second', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-2' }) - // Act render() - // Assert expect(screen.getByText('Credential 2')).toBeInTheDocument() }) it('should display third credential when currentCredentialId matches third', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-3' }) - // Act render() - // Assert expect(screen.getByText('Credential 3')).toBeInTheDocument() }) @@ -216,41 +184,33 @@ describe('CredentialSelector', () => { ['cred-2', 'Credential 2'], ['cred-3', 'Credential 3'], ])('should display %s credential name when currentCredentialId is %s', (credId, expectedName) => { - // Arrange const props = createDefaultProps({ currentCredentialId: credId }) - // Act render() - // Assert expect(screen.getByText(expectedName)).toBeInTheDocument() }) }) describe('credentials prop', () => { it('should render single credential correctly', () => { - // Arrange const props = createDefaultProps({ credentials: [createMockCredential()], currentCredentialId: 'cred-1', }) - // Act render() - // Assert expect(screen.getByText('Test Credential')).toBeInTheDocument() }) it('should render multiple credentials in dropdown', () => { - // Arrange const props = createDefaultProps({ credentials: createMockCredentials(5), currentCredentialId: 'cred-1', }) render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -259,23 +219,19 @@ describe('CredentialSelector', () => { }) it('should handle credentials with special characters in name', () => { - // Arrange const props = createDefaultProps({ credentials: [createMockCredential({ id: 'cred-special', name: 'Test & Credential ' })], currentCredentialId: 'cred-special', }) - // Act render() - // Assert expect(screen.getByText('Test & Credential ')).toBeInTheDocument() }) }) describe('onCredentialChange prop', () => { it('should be called when selecting a credential', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() @@ -284,11 +240,9 @@ describe('CredentialSelector', () => { const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) - // Click on second credential const credential2 = screen.getByText('Credential 2') fireEvent.click(credential2) - // Assert expect(mockOnChange).toHaveBeenCalledWith('cred-2') }) @@ -296,7 +250,6 @@ describe('CredentialSelector', () => { ['cred-2', 'Credential 2'], ['cred-3', 'Credential 3'], ])('should call onCredentialChange with %s when selecting %s', (credId, credentialName) => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() @@ -310,7 +263,6 @@ describe('CredentialSelector', () => { const credentialOption = within(portalContent).getByText(credentialName) fireEvent.click(credentialOption) - // Assert expect(mockOnChange).toHaveBeenCalledWith(credId) }) @@ -330,18 +282,14 @@ describe('CredentialSelector', () => { const credential1 = screen.getByText('Credential 1') fireEvent.click(credential1) - // Assert expect(mockOnChange).toHaveBeenCalledWith('cred-1') }) }) }) - // ========================================== // User Interactions - Test event handlers - // ========================================== describe('User Interactions', () => { it('should toggle dropdown open when trigger is clicked', () => { - // Arrange const props = createDefaultProps() render() @@ -357,24 +305,20 @@ describe('CredentialSelector', () => { }) it('should call onCredentialChange when clicking a credential item', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) const credential2 = screen.getByText('Credential 2') fireEvent.click(credential2) - // Assert expect(mockOnChange).toHaveBeenCalledTimes(1) expect(mockOnChange).toHaveBeenCalledWith('cred-2') }) it('should close dropdown after selecting a credential', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() @@ -393,7 +337,6 @@ describe('CredentialSelector', () => { }) it('should handle rapid consecutive clicks on trigger', () => { - // Arrange const props = createDefaultProps() render() @@ -428,19 +371,15 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Side Effects and Cleanup - Test useEffect behavior - // ========================================== describe('Side Effects and Cleanup', () => { it('should auto-select first credential when currentCredential is not found and credentials exist', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ currentCredentialId: 'non-existent-id', onCredentialChange: mockOnChange, }) - // Act render() // Assert - Should auto-select first credential @@ -448,14 +387,12 @@ describe('CredentialSelector', () => { }) it('should not call onCredentialChange when currentCredential is found', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ currentCredentialId: 'cred-2', onCredentialChange: mockOnChange, }) - // Act render() // Assert - Should not auto-select @@ -463,7 +400,6 @@ describe('CredentialSelector', () => { }) it('should not call onCredentialChange when credentials array is empty', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ currentCredentialId: 'cred-1', @@ -471,7 +407,6 @@ describe('CredentialSelector', () => { onCredentialChange: mockOnChange, }) - // Act render() // Assert - Should not call since no credentials to select @@ -479,7 +414,6 @@ describe('CredentialSelector', () => { }) it('should auto-select when credentials change and currentCredential becomes invalid', async () => { - // Arrange const mockOnChange = vi.fn() const initialCredentials = createMockCredentials(3) const props = createDefaultProps({ @@ -510,7 +444,6 @@ describe('CredentialSelector', () => { }) it('should not trigger auto-select effect on every render with same props', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) @@ -524,12 +457,9 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Callback Stability and Memoization - Test useCallback behavior - // ========================================== describe('Callback Stability and Memoization', () => { it('should have stable handleCredentialChange callback', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() @@ -545,7 +475,6 @@ describe('CredentialSelector', () => { }) it('should update handleCredentialChange when onCredentialChange changes', () => { - // Arrange const mockOnChange1 = vi.fn() const mockOnChange2 = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange1 }) @@ -567,15 +496,11 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Memoization Logic and Dependencies - Test useMemo behavior - // ========================================== describe('Memoization Logic and Dependencies', () => { it('should find currentCredential by id', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-2' }) - // Act render() // Assert - Should display credential 2 @@ -583,7 +508,6 @@ describe('CredentialSelector', () => { }) it('should update currentCredential when currentCredentialId changes', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-1' }) const { rerender } = render() @@ -598,7 +522,6 @@ describe('CredentialSelector', () => { }) it('should update currentCredential when credentials array changes', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-1' }) const { rerender } = render() @@ -616,14 +539,12 @@ describe('CredentialSelector', () => { }) it('should return undefined currentCredential when id not found', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ currentCredentialId: 'non-existent', onCredentialChange: mockOnChange, }) - // Act render() // Assert - Should trigger auto-select effect @@ -631,17 +552,13 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Component Memoization - Test React.memo behavior - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Assert expect(CredentialSelector.$$typeof).toBe(Symbol.for('react.memo')) }) it('should not re-render when props remain the same', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) const renderSpy = vi.fn() @@ -652,7 +569,6 @@ describe('CredentialSelector', () => { } const MemoizedTracked = React.memo(TrackedCredentialSelector) - // Act const { rerender } = render() rerender() @@ -661,22 +577,18 @@ describe('CredentialSelector', () => { }) it('should re-render when currentCredentialId changes', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-1' }) const { rerender } = render() // Assert initial expect(screen.getByText('Credential 1')).toBeInTheDocument() - // Act rerender() - // Assert expect(screen.getByText('Credential 2')).toBeInTheDocument() }) it('should re-render when credentials array reference changes', () => { - // Arrange const props = createDefaultProps() const { rerender } = render() @@ -686,12 +598,10 @@ describe('CredentialSelector', () => { ] rerender() - // Assert expect(screen.getByText('New Name 1')).toBeInTheDocument() }) it('should re-render when onCredentialChange reference changes', () => { - // Arrange const mockOnChange1 = vi.fn() const mockOnChange2 = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange1 }) @@ -711,18 +621,13 @@ describe('CredentialSelector', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty credentials array', () => { - // Arrange const props = createDefaultProps({ credentials: [], currentCredentialId: 'cred-1', }) - // Act render() // Assert - Should render without crashing @@ -730,7 +635,6 @@ describe('CredentialSelector', () => { }) it('should handle undefined avatar_url in credential', () => { - // Arrange const credentialWithoutAvatar = createMockCredential({ id: 'cred-no-avatar', name: 'No Avatar Credential', @@ -741,7 +645,6 @@ describe('CredentialSelector', () => { currentCredentialId: 'cred-no-avatar', }) - // Act const { container } = render() // Assert - Should render without crashing and show first letter fallback @@ -754,7 +657,6 @@ describe('CredentialSelector', () => { }) it('should handle empty string name in credential', () => { - // Arrange const credentialWithEmptyName = createMockCredential({ id: 'cred-empty-name', name: '', @@ -764,7 +666,6 @@ describe('CredentialSelector', () => { currentCredentialId: 'cred-empty-name', }) - // Act render() // Assert - Should render without crashing @@ -772,7 +673,6 @@ describe('CredentialSelector', () => { }) it('should handle very long credential name', () => { - // Arrange const longName = 'A'.repeat(200) const credentialWithLongName = createMockCredential({ id: 'cred-long-name', @@ -783,15 +683,12 @@ describe('CredentialSelector', () => { currentCredentialId: 'cred-long-name', }) - // Act render() - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle special characters in credential name', () => { - // Arrange const specialName = 'æ”‹èŻ• Credential & "quoted"' const credentialWithSpecialName = createMockCredential({ id: 'cred-special', @@ -802,15 +699,12 @@ describe('CredentialSelector', () => { currentCredentialId: 'cred-special', }) - // Act render() - // Assert expect(screen.getByText(specialName)).toBeInTheDocument() }) it('should handle numeric id as string', () => { - // Arrange const credentialWithNumericId = createMockCredential({ id: '123456', name: 'Numeric ID Credential', @@ -820,30 +714,24 @@ describe('CredentialSelector', () => { currentCredentialId: '123456', }) - // Act render() - // Assert expect(screen.getByText('Numeric ID Credential')).toBeInTheDocument() }) it('should handle large number of credentials', () => { - // Arrange const manyCredentials = createMockCredentials(100) const props = createDefaultProps({ credentials: manyCredentials, currentCredentialId: 'cred-50', }) - // Act render() - // Assert expect(screen.getByText('Credential 50')).toBeInTheDocument() }) it('should handle credential selection with duplicate names', () => { - // Arrange const mockOnChange = vi.fn() const duplicateCredentials = [ createMockCredential({ id: 'cred-1', name: 'Same Name' }), @@ -855,7 +743,6 @@ describe('CredentialSelector', () => { onCredentialChange: mockOnChange, }) - // Act render() const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -865,7 +752,6 @@ describe('CredentialSelector', () => { const sameNameElements = screen.getAllByText('Same Name') expect(sameNameElements.length).toBe(3) - // Click the last dropdown item (cred-2 in dropdown) fireEvent.click(sameNameElements[2]) // Assert - Should call with the correct id even with duplicate names @@ -873,12 +759,10 @@ describe('CredentialSelector', () => { }) it('should not crash when clicking credential after unmount', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) const { unmount } = render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -891,7 +775,6 @@ describe('CredentialSelector', () => { }) it('should handle whitespace-only credential name', () => { - // Arrange const credentialWithWhitespace = createMockCredential({ id: 'cred-whitespace', name: ' ', @@ -901,7 +784,6 @@ describe('CredentialSelector', () => { currentCredentialId: 'cred-whitespace', }) - // Act render() // Assert - Should render without crashing @@ -909,58 +791,43 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Styling and CSS Classes - // ========================================== describe('Styling', () => { it('should apply overflow-hidden class to trigger', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert const trigger = screen.getByTestId('portal-trigger') expect(trigger).toHaveClass('overflow-hidden') }) it('should apply grow class to trigger', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert const trigger = screen.getByTestId('portal-trigger') expect(trigger).toHaveClass('grow') }) it('should apply z-10 class to dropdown content', () => { - // Arrange const props = createDefaultProps() render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) - // Assert const content = screen.getByTestId('portal-content') expect(content).toHaveClass('z-10') }) }) - // ========================================== // Integration with Child Components - // ========================================== describe('Integration with Child Components', () => { it('should pass currentCredential to Trigger component', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-2' }) - // Act render() // Assert - Trigger should display the correct credential @@ -968,7 +835,6 @@ describe('CredentialSelector', () => { }) it('should pass isOpen state to Trigger component', () => { - // Arrange const props = createDefaultProps() render() @@ -985,11 +851,9 @@ describe('CredentialSelector', () => { }) it('should pass credentials to List component', () => { - // Arrange const props = createDefaultProps() render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -1000,11 +864,9 @@ describe('CredentialSelector', () => { }) it('should pass currentCredentialId to List component', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-2' }) render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -1015,12 +877,10 @@ describe('CredentialSelector', () => { }) it('should pass handleCredentialChange to List component', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) const credential3 = screen.getByText('Credential 3') @@ -1031,9 +891,7 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Portal Configuration - // ========================================== describe('Portal Configuration', () => { it('should configure PortalToFollowElem with placement bottom-start', () => { // This test verifies the portal is configured correctly diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx new file mode 100644 index 0000000000..7aa6c8f0c3 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx @@ -0,0 +1,32 @@ +import type { DataSourceCredential } from '@/types/pipeline' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Item from '../item' + +vi.mock('@/app/components/datasets/common/credential-icon', () => ({ + CredentialIcon: () => , +})) + +describe('CredentialSelectorItem', () => { + const defaultProps = { + credential: { id: 'cred-1', name: 'My Account', avatar_url: 'https://example.com/avatar.png' } as DataSourceCredential, + isSelected: false, + onCredentialChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render credential name and icon', () => { + render() + expect(screen.getByText('My Account')).toBeInTheDocument() + expect(screen.getByTestId('credential-icon')).toBeInTheDocument() + }) + + it('should call onCredentialChange with credential id on click', () => { + render() + fireEvent.click(screen.getByText('My Account')) + expect(defaultProps.onCredentialChange).toHaveBeenCalledWith('cred-1') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/list.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/list.spec.tsx new file mode 100644 index 0000000000..e67ee24524 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/list.spec.tsx @@ -0,0 +1,37 @@ +import type { DataSourceCredential } from '@/types/pipeline' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import List from '../list' + +vi.mock('@/app/components/datasets/common/credential-icon', () => ({ + CredentialIcon: () => , +})) + +describe('CredentialSelectorList', () => { + const mockCredentials: DataSourceCredential[] = [ + { id: 'cred-1', name: 'Account A', avatar_url: '' } as DataSourceCredential, + { id: 'cred-2', name: 'Account B', avatar_url: '' } as DataSourceCredential, + ] + + const defaultProps = { + currentCredentialId: 'cred-1', + credentials: mockCredentials, + onCredentialChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render all credentials', () => { + render() + expect(screen.getByText('Account A')).toBeInTheDocument() + expect(screen.getByText('Account B')).toBeInTheDocument() + }) + + it('should call onCredentialChange on item click', () => { + render() + fireEvent.click(screen.getByText('Account B')) + expect(defaultProps.onCredentialChange).toHaveBeenCalledWith('cred-2') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx new file mode 100644 index 0000000000..3e5cec12b8 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx @@ -0,0 +1,36 @@ +import type { DataSourceCredential } from '@/types/pipeline' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Trigger from '../trigger' + +vi.mock('@/app/components/datasets/common/credential-icon', () => ({ + CredentialIcon: () => , +})) + +describe('CredentialSelectorTrigger', () => { + it('should render credential name when provided', () => { + render( + , + ) + expect(screen.getByText('Account A')).toBeInTheDocument() + }) + + it('should render empty name when no credential', () => { + render() + expect(screen.getByTestId('credential-icon')).toBeInTheDocument() + }) + + it('should apply hover style when open', () => { + const { container } = render( + , + ) + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('bg-state-base-hover') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx deleted file mode 100644 index 31be2cdba6..0000000000 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx +++ /dev/null @@ -1,658 +0,0 @@ -import type { DataSourceCredential } from '@/types/pipeline' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import Header from './header' - -// Mock CredentialTypeEnum to avoid deep import chain issues -enum MockCredentialTypeEnum { - OAUTH2 = 'oauth2', - API_KEY = 'api_key', -} - -// Mock plugin-auth module to avoid deep import chain issues -vi.mock('@/app/components/plugins/plugin-auth', () => ({ - CredentialTypeEnum: { - OAUTH2: 'oauth2', - API_KEY: 'api_key', - }, -})) - -// Mock portal-to-follow-elem - required for CredentialSelector -vi.mock('@/app/components/base/portal-to-follow-elem', () => { - const MockPortalToFollowElem = ({ children, open }: any) => { - return ( -
- {React.Children.map(children, (child: any) => { - if (!child) - return null - return React.cloneElement(child, { __portalOpen: open }) - })} -
- ) - } - - const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => ( -
- {children} -
- ) - - const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => { - if (!__portalOpen) - return null - return ( -
- {children} -
- ) - } - - return { - PortalToFollowElem: MockPortalToFollowElem, - PortalToFollowElemTrigger: MockPortalToFollowElemTrigger, - PortalToFollowElemContent: MockPortalToFollowElemContent, - } -}) - -// ========================================== -// Test Data Builders -// ========================================== -const createMockCredential = (overrides?: Partial): DataSourceCredential => ({ - id: 'cred-1', - name: 'Test Credential', - avatar_url: 'https://example.com/avatar.png', - credential: { key: 'value' }, - is_default: false, - type: MockCredentialTypeEnum.OAUTH2 as unknown as DataSourceCredential['type'], - ...overrides, -}) - -const createMockCredentials = (count: number = 3): DataSourceCredential[] => - Array.from({ length: count }, (_, i) => - createMockCredential({ - id: `cred-${i + 1}`, - name: `Credential ${i + 1}`, - avatar_url: `https://example.com/avatar-${i + 1}.png`, - is_default: i === 0, - })) - -type HeaderProps = React.ComponentProps - -const createDefaultProps = (overrides?: Partial): HeaderProps => ({ - docTitle: 'Documentation', - docLink: 'https://docs.example.com', - pluginName: 'Test Plugin', - currentCredentialId: 'cred-1', - onCredentialChange: vi.fn(), - credentials: createMockCredentials(), - ...overrides, -}) - -describe('Header', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ========================================== - // Rendering Tests - // ========================================== - describe('Rendering', () => { - it('should render without crashing', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - expect(screen.getByText('Documentation')).toBeInTheDocument() - }) - - it('should render documentation link with correct attributes', () => { - // Arrange - const props = createDefaultProps({ - docTitle: 'API Docs', - docLink: 'https://api.example.com/docs', - }) - - // Act - render(
) - - // Assert - const link = screen.getByRole('link', { name: /API Docs/i }) - expect(link).toHaveAttribute('href', 'https://api.example.com/docs') - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') - }) - - it('should render document title with title attribute', () => { - // Arrange - const props = createDefaultProps({ docTitle: 'My Documentation' }) - - // Act - render(
) - - // Assert - const titleSpan = screen.getByText('My Documentation') - expect(titleSpan).toHaveAttribute('title', 'My Documentation') - }) - - it('should render CredentialSelector with correct props', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - CredentialSelector should render current credential name - expect(screen.getByText('Credential 1')).toBeInTheDocument() - }) - - it('should render configuration button', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should render book icon in documentation link', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - RiBookOpenLine renders as SVG - const link = screen.getByRole('link') - const svg = link.querySelector('svg') - expect(svg).toBeInTheDocument() - }) - - it('should render divider between credential selector and configuration button', () => { - // Arrange - const props = createDefaultProps() - - // Act - const { container } = render(
) - - // Assert - Divider component should be rendered - // Divider typically renders as a div with specific styling - const divider = container.querySelector('[class*="divider"]') || container.querySelector('.mx-1.h-3\\.5') - expect(divider).toBeInTheDocument() - }) - }) - - // ========================================== - // Props Testing - // ========================================== - describe('Props', () => { - describe('docTitle prop', () => { - it('should display the document title', () => { - // Arrange - const props = createDefaultProps({ docTitle: 'Getting Started Guide' }) - - // Act - render(
) - - // Assert - expect(screen.getByText('Getting Started Guide')).toBeInTheDocument() - }) - - it.each([ - 'Quick Start', - 'API Reference', - 'Configuration Guide', - 'Plugin Documentation', - ])('should display "%s" as document title', (title) => { - // Arrange - const props = createDefaultProps({ docTitle: title }) - - // Act - render(
) - - // Assert - expect(screen.getByText(title)).toBeInTheDocument() - }) - }) - - describe('docLink prop', () => { - it('should set correct href on documentation link', () => { - // Arrange - const props = createDefaultProps({ docLink: 'https://custom.docs.com/guide' }) - - // Act - render(
) - - // Assert - const link = screen.getByRole('link') - expect(link).toHaveAttribute('href', 'https://custom.docs.com/guide') - }) - - it.each([ - 'https://docs.dify.ai', - 'https://example.com/api', - '/local/docs', - ])('should accept "%s" as docLink', (link) => { - // Arrange - const props = createDefaultProps({ docLink: link }) - - // Act - render(
) - - // Assert - expect(screen.getByRole('link')).toHaveAttribute('href', link) - }) - }) - - describe('pluginName prop', () => { - it('should pass pluginName to translation function', () => { - // Arrange - const props = createDefaultProps({ pluginName: 'MyPlugin' }) - - // Act - render(
) - - // Assert - The translation mock returns the key with options - // Tooltip uses the translated content - expect(screen.getByRole('button')).toBeInTheDocument() - }) - }) - - describe('onClickConfiguration prop', () => { - it('should call onClickConfiguration when configuration icon is clicked', () => { - // Arrange - const mockOnClick = vi.fn() - const props = createDefaultProps({ onClickConfiguration: mockOnClick }) - render(
) - - // Act - Find the configuration button and click the icon inside - // The button contains the RiEqualizer2Line icon with onClick handler - const configButton = screen.getByRole('button') - const configIcon = configButton.querySelector('svg') - expect(configIcon).toBeInTheDocument() - fireEvent.click(configIcon!) - - // Assert - expect(mockOnClick).toHaveBeenCalledTimes(1) - }) - - it('should not crash when onClickConfiguration is undefined', () => { - // Arrange - const props = createDefaultProps({ onClickConfiguration: undefined }) - render(
) - - // Act - Find the configuration button and click the icon inside - const configButton = screen.getByRole('button') - const configIcon = configButton.querySelector('svg') - expect(configIcon).toBeInTheDocument() - fireEvent.click(configIcon!) - - // Assert - Component should still be rendered (no crash) - expect(screen.getByRole('button')).toBeInTheDocument() - }) - }) - - describe('CredentialSelector props passthrough', () => { - it('should pass currentCredentialId to CredentialSelector', () => { - // Arrange - const props = createDefaultProps({ currentCredentialId: 'cred-2' }) - - // Act - render(
) - - // Assert - Should display the second credential - expect(screen.getByText('Credential 2')).toBeInTheDocument() - }) - - it('should pass credentials to CredentialSelector', () => { - // Arrange - const customCredentials = [ - createMockCredential({ id: 'custom-1', name: 'Custom Credential' }), - ] - const props = createDefaultProps({ - credentials: customCredentials, - currentCredentialId: 'custom-1', - }) - - // Act - render(
) - - // Assert - expect(screen.getByText('Custom Credential')).toBeInTheDocument() - }) - - it('should pass onCredentialChange to CredentialSelector', () => { - // Arrange - const mockOnChange = vi.fn() - const props = createDefaultProps({ onCredentialChange: mockOnChange }) - render(
) - - // Act - Open dropdown and select a credential - // Use getAllByTestId and select the first one (CredentialSelector's trigger) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[0]) - const credential2 = screen.getByText('Credential 2') - fireEvent.click(credential2) - - // Assert - expect(mockOnChange).toHaveBeenCalledWith('cred-2') - }) - }) - }) - - // ========================================== - // User Interactions - // ========================================== - describe('User Interactions', () => { - it('should open external link in new tab when clicking documentation link', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - Link has target="_blank" for new tab - const link = screen.getByRole('link') - expect(link).toHaveAttribute('target', '_blank') - }) - - it('should allow credential selection through CredentialSelector', () => { - // Arrange - const mockOnChange = vi.fn() - const props = createDefaultProps({ onCredentialChange: mockOnChange }) - render(
) - - // Act - Open dropdown (use first trigger which is CredentialSelector's) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[0]) - - // Assert - Dropdown should be open - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - - it('should trigger configuration callback when clicking config icon', () => { - // Arrange - const mockOnConfig = vi.fn() - const props = createDefaultProps({ onClickConfiguration: mockOnConfig }) - const { container } = render(
) - - // Act - const configIcon = container.querySelector('.h-4.w-4') - fireEvent.click(configIcon!) - - // Assert - expect(mockOnConfig).toHaveBeenCalled() - }) - }) - - // ========================================== - // Component Memoization - // ========================================== - describe('Component Memoization', () => { - it('should be wrapped with React.memo', () => { - // Assert - expect(Header.$$typeof).toBe(Symbol.for('react.memo')) - }) - - it('should not re-render when props remain the same', () => { - // Arrange - const props = createDefaultProps() - const renderSpy = vi.fn() - - const TrackedHeader: React.FC = (trackedProps) => { - renderSpy() - return
- } - const MemoizedTracked = React.memo(TrackedHeader) - - // Act - const { rerender } = render() - rerender() - - // Assert - Should only render once due to same props - expect(renderSpy).toHaveBeenCalledTimes(1) - }) - - it('should re-render when docTitle changes', () => { - // Arrange - const props = createDefaultProps({ docTitle: 'Original Title' }) - const { rerender } = render(
) - - // Assert initial - expect(screen.getByText('Original Title')).toBeInTheDocument() - - // Act - rerender(
) - - // Assert - expect(screen.getByText('Updated Title')).toBeInTheDocument() - }) - - it('should re-render when currentCredentialId changes', () => { - // Arrange - const props = createDefaultProps({ currentCredentialId: 'cred-1' }) - const { rerender } = render(
) - - // Assert initial - expect(screen.getByText('Credential 1')).toBeInTheDocument() - - // Act - rerender(
) - - // Assert - expect(screen.getByText('Credential 2')).toBeInTheDocument() - }) - }) - - // ========================================== - // Edge Cases - // ========================================== - describe('Edge Cases', () => { - it('should handle empty docTitle', () => { - // Arrange - const props = createDefaultProps({ docTitle: '' }) - - // Act - render(
) - - // Assert - Should render without crashing - const link = screen.getByRole('link') - expect(link).toBeInTheDocument() - }) - - it('should handle very long docTitle', () => { - // Arrange - const longTitle = 'A'.repeat(200) - const props = createDefaultProps({ docTitle: longTitle }) - - // Act - render(
) - - // Assert - expect(screen.getByText(longTitle)).toBeInTheDocument() - }) - - it('should handle special characters in docTitle', () => { - // Arrange - const specialTitle = 'Docs & Guide "Special"' - const props = createDefaultProps({ docTitle: specialTitle }) - - // Act - render(
) - - // Assert - expect(screen.getByText(specialTitle)).toBeInTheDocument() - }) - - it('should handle empty credentials array', () => { - // Arrange - const props = createDefaultProps({ - credentials: [], - currentCredentialId: '', - }) - - // Act - render(
) - - // Assert - Should render without crashing - expect(screen.getByRole('link')).toBeInTheDocument() - }) - - it('should handle special characters in pluginName', () => { - // Arrange - const props = createDefaultProps({ pluginName: 'Plugin & Tool ' }) - - // Act - render(
) - - // Assert - Should render without crashing - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should handle unicode characters in docTitle', () => { - // Arrange - const props = createDefaultProps({ docTitle: 'æ–‡æĄŁèŻŽæ˜Ž 📚' }) - - // Act - render(
) - - // Assert - expect(screen.getByText('æ–‡æĄŁèŻŽæ˜Ž 📚')).toBeInTheDocument() - }) - }) - - // ========================================== - // Styling - // ========================================== - describe('Styling', () => { - it('should apply correct classes to container', () => { - // Arrange - const props = createDefaultProps() - - // Act - const { container } = render(
) - - // Assert - const rootDiv = container.firstChild as HTMLElement - expect(rootDiv).toHaveClass('flex', 'items-center', 'justify-between', 'gap-x-2') - }) - - it('should apply correct classes to documentation link', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - const link = screen.getByRole('link') - expect(link).toHaveClass('system-xs-medium', 'text-text-accent') - }) - - it('should apply shrink-0 to documentation link', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - const link = screen.getByRole('link') - expect(link).toHaveClass('shrink-0') - }) - }) - - // ========================================== - // Integration Tests - // ========================================== - describe('Integration', () => { - it('should work with full credential workflow', () => { - // Arrange - const mockOnCredentialChange = vi.fn() - const props = createDefaultProps({ - onCredentialChange: mockOnCredentialChange, - currentCredentialId: 'cred-1', - }) - render(
) - - // Assert initial state - expect(screen.getByText('Credential 1')).toBeInTheDocument() - - // Act - Open dropdown and select different credential - // Use first trigger which is CredentialSelector's - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[0]) - - const credential3 = screen.getByText('Credential 3') - fireEvent.click(credential3) - - // Assert - expect(mockOnCredentialChange).toHaveBeenCalledWith('cred-3') - }) - - it('should display all components together correctly', () => { - // Arrange - const mockOnConfig = vi.fn() - const props = createDefaultProps({ - docTitle: 'Integration Test Docs', - docLink: 'https://test.com/docs', - pluginName: 'TestPlugin', - onClickConfiguration: mockOnConfig, - }) - - // Act - render(
) - - // Assert - All main elements present - expect(screen.getByText('Credential 1')).toBeInTheDocument() // CredentialSelector - expect(screen.getByRole('button')).toBeInTheDocument() // Config button - expect(screen.getByText('Integration Test Docs')).toBeInTheDocument() // Doc link - expect(screen.getByRole('link')).toHaveAttribute('href', 'https://test.com/docs') - }) - }) - - // ========================================== - // Accessibility - // ========================================== - describe('Accessibility', () => { - it('should have accessible link', () => { - // Arrange - const props = createDefaultProps({ docTitle: 'Accessible Docs' }) - - // Act - render(
) - - // Assert - const link = screen.getByRole('link', { name: /Accessible Docs/i }) - expect(link).toBeInTheDocument() - }) - - it('should have accessible button for configuration', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - }) - - it('should have noopener noreferrer for security on external links', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - const link = screen.getByRole('link') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') - }) - }) -}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx index 66f13be84f..87010638b2 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx @@ -1,21 +1,15 @@ import type { FileItem } from '@/models/datasets' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import LocalFile from './index' +import LocalFile from '../index' // Mock the hook const mockUseLocalFileUpload = vi.fn() -vi.mock('./hooks/use-local-file-upload', () => ({ +vi.mock('../hooks/use-local-file-upload', () => ({ useLocalFileUpload: (...args: unknown[]) => mockUseLocalFileUpload(...args), })) // Mock react-i18next for sub-components -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock theme hook for sub-components vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx index 7754ba6970..df7fe3540b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx @@ -1,9 +1,9 @@ -import type { FileListItemProps } from './file-list-item' +import type { FileListItemProps } from '../file-list-item' import type { CustomFile as File, FileItem } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' -import FileListItem from './file-list-item' +import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' +import FileListItem from '../file-list-item' // Mock theme hook - can be changed per test let mockTheme = 'light' diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx similarity index 83% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx index 21742b731c..74b4a3b194 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx @@ -1,33 +1,12 @@ import type { RefObject } from 'react' -import type { UploadDropzoneProps } from './upload-dropzone' +import type { UploadDropzoneProps } from '../upload-dropzone' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import UploadDropzone from './upload-dropzone' +import UploadDropzone from '../upload-dropzone' // Helper to create mock ref objects for testing const createMockRef = (value: T | null = null): RefObject => ({ current: value }) -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const translations: Record = { - 'stepOne.uploader.button': 'Drag and drop files, or', - 'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or', - 'stepOne.uploader.browse': 'Browse', - 'stepOne.uploader.tip': 'Supports {{supportTypes}}, Max {{size}}MB each, up to {{batchCount}} files at a time, {{totalCount}} files total', - } - let result = translations[key] || key - if (options && typeof options === 'object') { - Object.entries(options).forEach(([k, v]) => { - result = result.replace(`{{${k}}}`, String(v)) - }) - } - return result - }, - }), -})) - describe('UploadDropzone', () => { const defaultProps: UploadDropzoneProps = { dropRef: createMockRef() as RefObject, @@ -78,20 +57,19 @@ describe('UploadDropzone', () => { it('should render browse label when extensions are allowed', () => { render() - expect(screen.getByText('Browse')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument() }) it('should not render browse label when no extensions allowed', () => { render() - expect(screen.queryByText('Browse')).not.toBeInTheDocument() + expect(screen.queryByText('datasetCreation.stepOne.uploader.browse')).not.toBeInTheDocument() }) it('should render file size and count limits', () => { render() - const tipText = screen.getByText(/Supports.*Max.*15MB/i) - expect(tipText).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.tip/)).toBeInTheDocument() }) }) @@ -122,13 +100,13 @@ describe('UploadDropzone', () => { it('should show batch upload text when supportBatchUpload is true', () => { render() - expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument() }) it('should show single file text when supportBatchUpload is false', () => { render() - expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument() }) }) @@ -161,7 +139,7 @@ describe('UploadDropzone', () => { const onSelectFile = vi.fn() render() - const browseLabel = screen.getByText('Browse') + const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse') fireEvent.click(browseLabel) expect(onSelectFile).toHaveBeenCalledTimes(1) @@ -215,7 +193,7 @@ describe('UploadDropzone', () => { it('should have cursor-pointer on browse label', () => { render() - const browseLabel = screen.getByText('Browse') + const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse') expect(browseLabel).toHaveClass('cursor-pointer') }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx index 6248b70506..bc9ce04beb 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import type { CustomFile, FileItem } from '@/models/datasets' import { act, render, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' +import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' // Mock notify function - defined before mocks const mockNotify = vi.fn() @@ -32,12 +32,6 @@ vi.mock('@/utils/format', () => ({ })) // Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock locale context vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', @@ -48,7 +42,6 @@ vi.mock('@/i18n-config/language', () => ({ LanguagesSupported: ['en-US', 'zh-Hans'], })) -// Mock config vi.mock('@/config', () => ({ IS_CE_EDITION: false, })) @@ -62,7 +55,7 @@ const mockGetState = vi.fn(() => ({ })) const mockStore = { getState: mockGetState } -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ useDataSourceStoreWithSelector: vi.fn((selector: (state: { localFileList: FileItem[] }) => FileItem[]) => selector({ localFileList: [] }), ), @@ -93,7 +86,7 @@ vi.mock('@/service/base', () => ({ })) // Import after all mocks are set up -const { useLocalFileUpload } = await import('./use-local-file-upload') +const { useLocalFileUpload } = await import('../use-local-file-upload') const { ToastContext } = await import('@/app/components/base/toast') const createWrapper = () => { @@ -728,7 +721,7 @@ describe('useLocalFileUpload', () => { describe('file upload limit', () => { it('should reject files exceeding total file upload limit', async () => { // Mock store to return existing files - const { useDataSourceStoreWithSelector } = vi.mocked(await import('../../store')) + const { useDataSourceStoreWithSelector } = vi.mocked(await import('../../../store')) const existingFiles: FileItem[] = Array.from({ length: 8 }, (_, i) => ({ fileID: `existing-${i}`, file: { name: `existing-${i}.pdf`, size: 1024 } as CustomFile, diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx index 21e79ef92e..894ee60060 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx @@ -3,13 +3,7 @@ import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { VarKindType } from '@/app/components/workflow/nodes/_base/types' -import OnlineDocuments from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import OnlineDocuments from '../index' // Mock useDocLink - context hook requires mocking const mockDocLink = vi.fn((path?: string) => `https://docs.example.com${path || ''}`) @@ -20,13 +14,13 @@ vi.mock('@/context/i18n', () => ({ // Mock dataset-detail context - context provider requires mocking let mockPipelineId = 'pipeline-123' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (s: any) => any) => selector({ dataset: { pipeline_id: mockPipelineId } }), + useDatasetDetailContextWithSelector: (selector: (s: Record) => unknown) => selector({ dataset: { pipeline_id: mockPipelineId } }), })) // Mock modal context - context provider requires mocking const mockSetShowAccountSettingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: (selector: (s: any) => any) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), + useModalContextSelector: (selector: (s: Record) => unknown) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), })) // Mock ssePost - API service requires mocking @@ -60,7 +54,6 @@ vi.mock('@/service/use-datasource', () => ({ // Note: zustand/react/shallow useShallow is imported directly (simple utility function) -// Mock store const mockStoreState = { documentsData: [] as DataSourceNotionWorkspace[], searchValue: '', @@ -76,22 +69,22 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../store', () => ({ - useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState), +vi.mock('../../store', () => ({ + useDataSourceStoreWithSelector: (selector: (s: Record) => unknown) => selector(mockStoreState as unknown as Record), useDataSourceStore: () => mockDataSourceStore, })) // Mock Header component -vi.mock('../base/header', () => ({ - default: (props: any) => ( +vi.mock('../../base/header', () => ({ + default: (props: Record) => (
- {props.docTitle} - {props.docLink} - {props.pluginName} - {props.currentCredentialId} - - - {props.credentials?.length || 0} + {props.docTitle as string} + {props.docLink as string} + {props.pluginName as string} + {props.currentCredentialId as string} + + + {(props.credentials as unknown[] | undefined)?.length || 0}
), })) @@ -111,23 +104,23 @@ vi.mock('@/app/components/base/notion-page-selector/search-input', () => ({ })) // Mock PageSelector component -vi.mock('./page-selector', () => ({ - default: (props: any) => ( +vi.mock('../page-selector', () => ({ + default: (props: Record) => (
- {props.checkedIds?.size || 0} - {props.searchValue} + {(props.checkedIds as Set | undefined)?.size || 0} + {props.searchValue as string} {String(props.canPreview)} {String(props.isMultipleChoice)} - {props.currentCredentialId} + {props.currentCredentialId as string} @@ -136,7 +129,7 @@ vi.mock('./page-selector', () => ({ })) // Mock Title component -vi.mock('./title', () => ({ +vi.mock('../title', () => ({ default: ({ name }: { name: string }) => (
{name} @@ -144,9 +137,6 @@ vi.mock('./title', () => ({ ), })) -// ========================================== -// Test Data Builders -// ========================================== const createMockNodeData = (overrides?: Partial): DataSourceNodeType => ({ title: 'Test Node', plugin_id: 'plugin-123', @@ -199,9 +189,6 @@ const createDefaultProps = (overrides?: Partial): OnlineDo ...overrides, }) -// ========================================== -// Test Suites -// ========================================== describe('OnlineDocuments', () => { beforeEach(() => { vi.clearAllMocks() @@ -229,105 +216,79 @@ describe('OnlineDocuments', () => { mockGetState.mockReturnValue(mockStoreState) }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('header')).toBeInTheDocument() }) it('should render Header with correct props', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-123' const props = createDefaultProps({ nodeData: createMockNodeData({ datasource_label: 'My Notion' }), }) - // Act render() - // Assert expect(screen.getByTestId('header-doc-title')).toHaveTextContent('Docs') expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('My Notion') expect(screen.getByTestId('header-credential-id')).toHaveTextContent('cred-123') }) it('should render Loading when documentsData is empty', () => { - // Arrange mockStoreState.documentsData = [] const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByRole('status')).toBeInTheDocument() }) it('should render PageSelector when documentsData has content', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('page-selector')).toBeInTheDocument() expect(screen.queryByRole('status')).not.toBeInTheDocument() }) it('should render Title with datasource_label', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ nodeData: createMockNodeData({ datasource_label: 'Notion Integration' }), }) - // Act render() - // Assert expect(screen.getByTestId('title-name')).toHaveTextContent('Notion Integration') }) it('should render SearchInput with current searchValue', () => { - // Arrange mockStoreState.searchValue = 'test search' mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() - // Act render() - // Assert const searchInput = screen.getByTestId('search-input-field') as HTMLInputElement expect(searchInput.value).toBe('test search') }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('nodeId prop', () => { it('should use nodeId in datasourceNodeRunURL for non-pipeline mode', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ nodeId: 'custom-node-id', isInPipeline: false, }) - // Act render() // Assert - Effect triggers ssePost with correct URL @@ -341,7 +302,6 @@ describe('OnlineDocuments', () => { describe('nodeData prop', () => { it('should pass datasource_parameters to ssePost', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const nodeData = createMockNodeData({ datasource_parameters: { @@ -351,10 +311,8 @@ describe('OnlineDocuments', () => { }) const props = createDefaultProps({ nodeData }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -367,17 +325,14 @@ describe('OnlineDocuments', () => { }) it('should pass plugin_id and provider_name to useGetDataSourceAuth', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'my-plugin-id', provider_name: 'my-provider', }) const props = createDefaultProps({ nodeData }) - // Act render() - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId: 'my-plugin-id', provider: 'my-provider', @@ -387,14 +342,11 @@ describe('OnlineDocuments', () => { describe('isInPipeline prop', () => { it('should use draft URL when isInPipeline is true', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ isInPipeline: true }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.stringContaining('/workflows/draft/'), expect.any(Object), @@ -403,14 +355,11 @@ describe('OnlineDocuments', () => { }) it('should use published URL when isInPipeline is false', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ isInPipeline: false }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.stringContaining('/workflows/published/'), expect.any(Object), @@ -419,52 +368,40 @@ describe('OnlineDocuments', () => { }) it('should pass canPreview as false to PageSelector when isInPipeline is true', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ isInPipeline: true }) - // Act render() - // Assert expect(screen.getByTestId('page-selector-can-preview')).toHaveTextContent('false') }) it('should pass canPreview as true to PageSelector when isInPipeline is false', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ isInPipeline: false }) - // Act render() - // Assert expect(screen.getByTestId('page-selector-can-preview')).toHaveTextContent('true') }) }) describe('supportBatchUpload prop', () => { it('should pass isMultipleChoice as true to PageSelector when supportBatchUpload is true', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ supportBatchUpload: true }) - // Act render() - // Assert expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent('true') }) it('should pass isMultipleChoice as false to PageSelector when supportBatchUpload is false', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ supportBatchUpload: false }) - // Act render() - // Assert expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent('false') }) @@ -473,71 +410,54 @@ describe('OnlineDocuments', () => { [false, 'false'], [undefined, 'true'], // Default value ])('should handle supportBatchUpload=%s correctly', (value, expected) => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ supportBatchUpload: value }) - // Act render() - // Assert expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent(expected) }) }) describe('onCredentialChange prop', () => { it('should pass onCredentialChange to Header', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) - // Act render() fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) }) - // ========================================== // Side Effects and Cleanup - // ========================================== describe('Side Effects and Cleanup', () => { it('should call getOnlineDocuments when currentCredentialId changes', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps() - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledTimes(1) }) it('should not call getOnlineDocuments when currentCredentialId is empty', () => { - // Arrange mockStoreState.currentCredentialId = '' const props = createDefaultProps() - // Act render() - // Assert expect(mockSsePost).not.toHaveBeenCalled() }) it('should pass correct body parameters to ssePost', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-123' const props = createDefaultProps() - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.any(String), { @@ -552,7 +472,6 @@ describe('OnlineDocuments', () => { }) it('should handle onDataSourceNodeCompleted callback correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockWorkspaces = [createMockWorkspace()] @@ -567,17 +486,14 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() - // Act render() - // Assert await waitFor(() => { expect(mockStoreState.setDocumentsData).toHaveBeenCalledWith(mockWorkspaces) }) }) it('should handle onDataSourceNodeError callback correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -590,10 +506,8 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() - // Act render() - // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -603,7 +517,6 @@ describe('OnlineDocuments', () => { }) it('should construct correct URL for draft workflow', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockPipelineId = 'pipeline-456' const props = createDefaultProps({ @@ -611,10 +524,8 @@ describe('OnlineDocuments', () => { isInPipeline: true, }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( '/rag/pipelines/pipeline-456/workflows/draft/datasource/nodes/node-789/run', expect.any(Object), @@ -623,7 +534,6 @@ describe('OnlineDocuments', () => { }) it('should construct correct URL for published workflow', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockPipelineId = 'pipeline-456' const props = createDefaultProps({ @@ -631,10 +541,8 @@ describe('OnlineDocuments', () => { isInPipeline: false, }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( '/rag/pipelines/pipeline-456/workflows/published/datasource/nodes/node-789/run', expect.any(Object), @@ -643,40 +551,31 @@ describe('OnlineDocuments', () => { }) }) - // ========================================== // Callback Stability and Memoization - // ========================================== describe('Callback Stability and Memoization', () => { it('should have stable handleSearchValueChange that updates store', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() render() - // Act const searchInput = screen.getByTestId('search-input-field') fireEvent.change(searchInput, { target: { value: 'new search value' } }) - // Assert expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('new search value') }) it('should have stable handleSelectPages that updates store', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('page-selector-select-btn')) - // Assert expect(mockStoreState.setSelectedPagesId).toHaveBeenCalled() expect(mockStoreState.setOnlineDocuments).toHaveBeenCalled() }) it('should have stable handlePreviewPage that updates store', () => { - // Arrange const mockPages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), ] @@ -684,34 +583,26 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('page-selector-preview-btn')) - // Assert expect(mockStoreState.setCurrentDocument).toHaveBeenCalled() }) it('should have stable handleSetting callback', () => { - // Arrange const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source', }) }) }) - // ========================================== // Memoization Logic and Dependencies - // ========================================== describe('Memoization Logic and Dependencies', () => { it('should compute PagesMapAndSelectedPagesId correctly from documentsData', () => { - // Arrange const mockPages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -721,7 +612,6 @@ describe('OnlineDocuments', () => { ] const props = createDefaultProps() - // Act render() // Assert - PageSelector receives the pagesMap (verified via mock) @@ -729,7 +619,6 @@ describe('OnlineDocuments', () => { }) it('should recompute PagesMapAndSelectedPagesId when documentsData changes', () => { - // Arrange const initialPages = [createMockPage({ page_id: 'page-1' })] mockStoreState.documentsData = [createMockWorkspace({ pages: initialPages })] const props = createDefaultProps() @@ -743,16 +632,13 @@ describe('OnlineDocuments', () => { mockStoreState.documentsData = [createMockWorkspace({ pages: newPages })] rerender() - // Assert expect(screen.getByTestId('page-selector')).toBeInTheDocument() }) it('should handle empty documentsData in PagesMapAndSelectedPagesId computation', () => { - // Arrange mockStoreState.documentsData = [] const props = createDefaultProps() - // Act render() // Assert - Should show loading instead of PageSelector @@ -760,26 +646,20 @@ describe('OnlineDocuments', () => { }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions and Event Handlers', () => { it('should handle search input changes', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() render() - // Act const searchInput = screen.getByTestId('search-input-field') fireEvent.change(searchInput, { target: { value: 'search query' } }) - // Assert expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('search query') }) it('should handle page selection', () => { - // Arrange const mockPages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -788,62 +668,48 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('page-selector-select-btn')) - // Assert expect(mockStoreState.setSelectedPagesId).toHaveBeenCalled() expect(mockStoreState.setOnlineDocuments).toHaveBeenCalled() }) it('should handle page preview', () => { - // Arrange const mockPages = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] mockStoreState.documentsData = [createMockWorkspace({ pages: mockPages })] const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('page-selector-preview-btn')) - // Assert expect(mockStoreState.setCurrentDocument).toHaveBeenCalled() }) it('should handle configuration button click', () => { - // Arrange const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source', }) }) it('should handle credential change', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) render() - // Act fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) - // ========================================== // API Calls Mocking - // ========================================== describe('API Calls', () => { it('should call ssePost with correct parameters', () => { - // Arrange mockStoreState.currentCredentialId = 'test-cred' const props = createDefaultProps({ nodeData: createMockNodeData({ @@ -854,10 +720,8 @@ describe('OnlineDocuments', () => { }), }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.any(String), { @@ -875,7 +739,6 @@ describe('OnlineDocuments', () => { }) it('should handle successful API response', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockData = [createMockWorkspace()] @@ -889,17 +752,14 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() - // Act render() - // Assert await waitFor(() => { expect(mockStoreState.setDocumentsData).toHaveBeenCalledWith(mockData) }) }) it('should handle API error response', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -911,10 +771,8 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() - // Act render() - // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -924,17 +782,14 @@ describe('OnlineDocuments', () => { }) it('should use useGetDataSourceAuth with correct parameters', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'notion-plugin', provider_name: 'notion-provider', }) const props = createDefaultProps({ nodeData }) - // Act render() - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId: 'notion-plugin', provider: 'notion-provider', @@ -942,7 +797,6 @@ describe('OnlineDocuments', () => { }) it('should pass credentials from useGetDataSourceAuth to Header', () => { - // Arrange const mockCredentials = [ createMockCredential({ id: 'cred-1', name: 'Credential 1' }), createMockCredential({ id: 'cred-2', name: 'Credential 2' }), @@ -952,69 +806,52 @@ describe('OnlineDocuments', () => { }) const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('2') }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty credentials array', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: { result: [] }, }) const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle undefined dataSourceAuth result', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: { result: undefined }, }) const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle null dataSourceAuth data', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: null, }) const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle documentsData with empty pages array', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace({ pages: [] })] const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('page-selector')).toBeInTheDocument() }) @@ -1023,7 +860,6 @@ describe('OnlineDocuments', () => { mockStoreState.documentsData = undefined as unknown as DataSourceNotionWorkspace[] const props = createDefaultProps() - // Act render() // Assert - Should show loading when documentsData is undefined @@ -1038,7 +874,6 @@ describe('OnlineDocuments', () => { nodeData.datasource_parameters = undefined const props = createDefaultProps({ nodeData }) - // Act render() // Assert - ssePost should be called with empty inputs @@ -1061,12 +896,11 @@ describe('OnlineDocuments', () => { const nodeData = createMockNodeData({ datasource_parameters: { // Object without 'value' key - should use the object itself - objWithoutValue: { type: VarKindType.constant, other: 'data' } as any, + objWithoutValue: { type: VarKindType.constant, other: 'data' } as Record & { type: VarKindType }, }, }) const props = createDefaultProps({ nodeData }) - // Act render() // Assert - The object without 'value' property should be passed as-is @@ -1084,62 +918,49 @@ describe('OnlineDocuments', () => { }) it('should handle multiple workspaces in documentsData', () => { - // Arrange mockStoreState.documentsData = [ createMockWorkspace({ workspace_id: 'ws-1', pages: [createMockPage({ page_id: 'page-1' })] }), createMockWorkspace({ workspace_id: 'ws-2', pages: [createMockPage({ page_id: 'page-2' })] }), ] const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('page-selector')).toBeInTheDocument() }) it('should handle special characters in searchValue', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() render() - // Act const searchInput = screen.getByTestId('search-input-field') fireEvent.change(searchInput, { target: { value: 'test' } }) - // Assert expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('test') }) it('should handle unicode characters in searchValue', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() render() - // Act const searchInput = screen.getByTestId('search-input-field') fireEvent.change(searchInput, { target: { value: 'æ”‹èŻ•æœçŽą 🔍' } }) - // Assert expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('æ”‹èŻ•æœçŽą 🔍') }) it('should handle empty string currentCredentialId', () => { - // Arrange mockStoreState.currentCredentialId = '' const props = createDefaultProps() - // Act render() - // Assert expect(mockSsePost).not.toHaveBeenCalled() }) it('should handle complex datasource_parameters with nested objects', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const nodeData = createMockNodeData({ datasource_parameters: { @@ -1149,10 +970,8 @@ describe('OnlineDocuments', () => { }) const props = createDefaultProps({ nodeData }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -1168,12 +987,10 @@ describe('OnlineDocuments', () => { }) it('should handle undefined pipelineId gracefully', () => { - // Arrange - mockPipelineId = undefined as any + mockPipelineId = undefined as unknown as string mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps() - // Act render() // Assert - Should still call ssePost with undefined in URL @@ -1181,9 +998,7 @@ describe('OnlineDocuments', () => { }) }) - // ========================================== // All Prop Variations - // ========================================== describe('Prop Variations', () => { it.each([ [{ isInPipeline: true, supportBatchUpload: true }], @@ -1191,14 +1006,11 @@ describe('OnlineDocuments', () => { [{ isInPipeline: false, supportBatchUpload: true }], [{ isInPipeline: false, supportBatchUpload: false }], ])('should render correctly with props %o', (propVariation) => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps(propVariation) - // Act render() - // Assert expect(screen.getByTestId('page-selector')).toBeInTheDocument() expect(screen.getByTestId('page-selector-can-preview')).toHaveTextContent( String(!propVariation.isInPipeline), @@ -1209,7 +1021,6 @@ describe('OnlineDocuments', () => { }) it('should use default values for optional props', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props: OnlineDocumentsProps = { nodeId: 'node-1', @@ -1218,7 +1029,6 @@ describe('OnlineDocuments', () => { // isInPipeline and supportBatchUpload are not provided } - // Act render() // Assert - Default values: isInPipeline = false, supportBatchUpload = true @@ -1227,12 +1037,8 @@ describe('OnlineDocuments', () => { }) }) - // ========================================== - // Integration Tests - // ========================================== describe('Integration', () => { it('should complete full workflow: load data -> search -> select -> preview', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockPages = [ createMockPage({ page_id: 'page-1', page_name: 'Test Page 1' }), @@ -1274,7 +1080,6 @@ describe('OnlineDocuments', () => { }) it('should handle error flow correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1286,10 +1091,8 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() - // Act render() - // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -1302,12 +1105,10 @@ describe('OnlineDocuments', () => { }) it('should handle credential change and refetch documents', () => { - // Arrange mockStoreState.currentCredentialId = 'initial-cred' const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) - // Act render() // Initial fetch @@ -1318,6 +1119,4 @@ describe('OnlineDocuments', () => { expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) - - // ========================================== }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx new file mode 100644 index 0000000000..3f0d7efb24 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Title from '../title' + +describe('OnlineDocumentTitle', () => { + it('should render title with name prop', () => { + render() + expect(screen.getByText('datasetPipeline.onlineDocument.pageSelectorTitle:{"name":"Notion Workspace"}')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/index.spec.tsx index 60da0e7c9f..bdfa809aed 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/index.spec.tsx @@ -1,38 +1,30 @@ -import type { NotionPageTreeItem, NotionPageTreeMap } from './index' +import type { NotionPageTreeItem, NotionPageTreeMap } from '../index' import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import PageSelector from './index' -import { recursivePushInParentDescendants } from './utils' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import PageSelector from '../index' +import { recursivePushInParentDescendants } from '../utils' // Mock react-window FixedSizeList - renders items directly for testing vi.mock('react-window', () => ({ - FixedSizeList: ({ children: ItemComponent, itemCount, itemData, itemKey }: any) => ( + FixedSizeList: ({ children: ItemComponent, itemCount, itemData, itemKey }: { children: React.ComponentType<{ index: number, style: React.CSSProperties, data: unknown }>, itemCount: number, itemData: unknown, itemKey?: (index: number, data: unknown) => string | number }) => ( <div data-testid="virtual-list"> {Array.from({ length: itemCount }).map((_, index) => ( <ItemComponent key={itemKey?.(index, itemData) || index} index={index} - style={{ top: index * 28, left: 0, right: 0, width: '100%', position: 'absolute' }} + style={{ top: index * 28, left: 0, right: 0, width: '100%', position: 'absolute' as const }} data={itemData} /> ))} </div> ), - areEqual: (prevProps: any, nextProps: any) => prevProps === nextProps, + areEqual: (prevProps: Record<string, unknown>, nextProps: Record<string, unknown>) => prevProps === nextProps, })) // Note: NotionIcon from @/app/components/base/ is NOT mocked - using real component per testing guidelines -// ========================================== // Helper Functions for Base Components -// ========================================== // Get checkbox element (uses data-testid pattern from base Checkbox component) const getCheckbox = () => document.querySelector('[data-testid^="checkbox-"]') as HTMLElement const getAllCheckboxes = () => document.querySelectorAll('[data-testid^="checkbox-"]') @@ -47,9 +39,6 @@ const isCheckboxChecked = (checkbox: Element) => checkbox.querySelector('[data-t // Check if checkbox is disabled by looking for disabled class const isCheckboxDisabled = (checkbox: Element) => checkbox.classList.contains('cursor-not-allowed') -// ========================================== -// Test Data Builders -// ========================================== const createMockPage = (overrides?: Partial<DataSourceNotionPage>): DataSourceNotionPage => ({ page_id: 'page-1', page_name: 'Test Page', @@ -99,46 +88,33 @@ const createHierarchicalPages = () => { return { list, pagesMap, rootPage, childPage1, childPage2, grandChild } } -// ========================================== -// Test Suites -// ========================================== describe('PageSelector', () => { beforeEach(() => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByTestId('virtual-list')).toBeInTheDocument() }) it('should render empty state when list is empty', () => { - // Arrange const props = createDefaultProps({ list: [], pagesMap: {}, }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() expect(screen.queryByTestId('virtual-list')).not.toBeInTheDocument() }) it('should render items using FixedSizeList', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -148,63 +124,47 @@ describe('PageSelector', () => { pagesMap: createMockPagesMap(pages), }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('Page 1')).toBeInTheDocument() expect(screen.getByText('Page 2')).toBeInTheDocument() }) it('should render checkboxes when isMultipleChoice is true', () => { - // Arrange const props = createDefaultProps({ isMultipleChoice: true }) - // Act render(<PageSelector {...props} />) - // Assert expect(getCheckbox()).toBeInTheDocument() }) it('should render radio buttons when isMultipleChoice is false', () => { - // Arrange const props = createDefaultProps({ isMultipleChoice: false }) - // Act render(<PageSelector {...props} />) - // Assert expect(getRadio()).toBeInTheDocument() }) it('should render preview button when canPreview is true', () => { - // Arrange const props = createDefaultProps({ canPreview: true }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() }) it('should not render preview button when canPreview is false', () => { - // Arrange const props = createDefaultProps({ canPreview: false }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.queryByText('common.dataSource.notion.selector.preview')).not.toBeInTheDocument() }) it('should render NotionIcon for each page', () => { - // Arrange const props = createDefaultProps() - // Act render(<PageSelector {...props} />) // Assert - NotionIcon renders svg when page_icon is null @@ -213,27 +173,20 @@ describe('PageSelector', () => { }) it('should render page name', () => { - // Arrange const props = createDefaultProps({ list: [createMockPage({ page_name: 'My Custom Page' })], pagesMap: createMockPagesMap([createMockPage({ page_name: 'My Custom Page' })]), }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('My Custom Page')).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('checkedIds prop', () => { it('should mark checkbox as checked when page is in checkedIds', () => { - // Arrange const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ list: [page], @@ -241,17 +194,14 @@ describe('PageSelector', () => { checkedIds: new Set(['page-1']), }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxChecked(checkbox)).toBe(true) }) it('should mark checkbox as unchecked when page is not in checkedIds', () => { - // Arrange const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ list: [page], @@ -259,30 +209,24 @@ describe('PageSelector', () => { checkedIds: new Set(), }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxChecked(checkbox)).toBe(false) }) it('should handle empty checkedIds', () => { - // Arrange const props = createDefaultProps({ checkedIds: new Set() }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxChecked(checkbox)).toBe(false) }) it('should handle multiple checked items', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -294,10 +238,8 @@ describe('PageSelector', () => { checkedIds: new Set(['page-1', 'page-3']), }) - // Act render(<PageSelector {...props} />) - // Assert const checkboxes = getAllCheckboxes() expect(isCheckboxChecked(checkboxes[0])).toBe(true) expect(isCheckboxChecked(checkboxes[1])).toBe(false) @@ -307,7 +249,6 @@ describe('PageSelector', () => { describe('disabledValue prop', () => { it('should disable checkbox when page is in disabledValue', () => { - // Arrange const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ list: [page], @@ -315,17 +256,14 @@ describe('PageSelector', () => { disabledValue: new Set(['page-1']), }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxDisabled(checkbox)).toBe(true) }) it('should not disable checkbox when page is not in disabledValue', () => { - // Arrange const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ list: [page], @@ -333,17 +271,14 @@ describe('PageSelector', () => { disabledValue: new Set(), }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxDisabled(checkbox)).toBe(false) }) it('should handle partial disabled items', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -354,10 +289,8 @@ describe('PageSelector', () => { disabledValue: new Set(['page-1']), }) - // Act render(<PageSelector {...props} />) - // Assert const checkboxes = getAllCheckboxes() expect(isCheckboxDisabled(checkboxes[0])).toBe(true) expect(isCheckboxDisabled(checkboxes[1])).toBe(false) @@ -366,7 +299,6 @@ describe('PageSelector', () => { describe('searchValue prop', () => { it('should filter pages by search value', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Apple Page' }), createMockPage({ page_id: 'page-2', page_name: 'Banana Page' }), @@ -378,7 +310,6 @@ describe('PageSelector', () => { searchValue: 'Apple', }) - // Act render(<PageSelector {...props} />) // Assert - Only pages containing "Apple" should be visible @@ -390,7 +321,6 @@ describe('PageSelector', () => { }) it('should show empty state when no pages match search', () => { - // Arrange const pages = [createMockPage({ page_id: 'page-1', page_name: 'Test Page' })] const props = createDefaultProps({ list: pages, @@ -398,15 +328,12 @@ describe('PageSelector', () => { searchValue: 'NonExistent', }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() }) it('should show all pages when searchValue is empty', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -417,16 +344,13 @@ describe('PageSelector', () => { searchValue: '', }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('Page 1')).toBeInTheDocument() expect(screen.getByText('Page 2')).toBeInTheDocument() }) it('should show breadcrumbs when searchValue is present', () => { - // Arrange const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ list, @@ -434,7 +358,6 @@ describe('PageSelector', () => { searchValue: 'Grandchild', }) - // Act render(<PageSelector {...props} />) // Assert - page name should be visible @@ -442,7 +365,6 @@ describe('PageSelector', () => { }) it('should perform case-sensitive search', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Apple Page' }), createMockPage({ page_id: 'page-2', page_name: 'apple page' }), @@ -453,7 +375,6 @@ describe('PageSelector', () => { searchValue: 'Apple', }) - // Act render(<PageSelector {...props} />) // Assert - Only 'Apple Page' should match (case-sensitive) @@ -465,95 +386,73 @@ describe('PageSelector', () => { describe('canPreview prop', () => { it('should show preview button when canPreview is true', () => { - // Arrange const props = createDefaultProps({ canPreview: true }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() }) it('should hide preview button when canPreview is false', () => { - // Arrange const props = createDefaultProps({ canPreview: false }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.queryByText('common.dataSource.notion.selector.preview')).not.toBeInTheDocument() }) it('should use default value true when canPreview is not provided', () => { - // Arrange const props = createDefaultProps() - delete (props as any).canPreview + delete (props as Partial<PageSelectorProps>).canPreview - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() }) }) describe('isMultipleChoice prop', () => { it('should render checkbox when isMultipleChoice is true', () => { - // Arrange const props = createDefaultProps({ isMultipleChoice: true }) - // Act render(<PageSelector {...props} />) - // Assert expect(getCheckbox()).toBeInTheDocument() expect(getRadio()).not.toBeInTheDocument() }) it('should render radio when isMultipleChoice is false', () => { - // Arrange const props = createDefaultProps({ isMultipleChoice: false }) - // Act render(<PageSelector {...props} />) - // Assert expect(getRadio()).toBeInTheDocument() expect(getCheckbox()).not.toBeInTheDocument() }) it('should use default value true when isMultipleChoice is not provided', () => { - // Arrange const props = createDefaultProps() - delete (props as any).isMultipleChoice + delete (props as Partial<PageSelectorProps>).isMultipleChoice - // Act render(<PageSelector {...props} />) - // Assert expect(getCheckbox()).toBeInTheDocument() }) }) describe('onSelect prop', () => { it('should call onSelect when checkbox is clicked', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) - // Act render(<PageSelector {...props} />) fireEvent.click(getCheckbox()) - // Assert expect(mockOnSelect).toHaveBeenCalledTimes(1) expect(mockOnSelect).toHaveBeenCalledWith(expect.any(Set)) }) it('should pass updated set to onSelect', () => { - // Arrange const mockOnSelect = vi.fn() const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ @@ -563,11 +462,9 @@ describe('PageSelector', () => { onSelect: mockOnSelect, }) - // Act render(<PageSelector {...props} />) fireEvent.click(getCheckbox()) - // Assert const calledSet = mockOnSelect.mock.calls[0][0] as Set<string> expect(calledSet.has('page-1')).toBe(true) }) @@ -575,7 +472,6 @@ describe('PageSelector', () => { describe('onPreview prop', () => { it('should call onPreview when preview button is clicked', () => { - // Arrange const mockOnPreview = vi.fn() const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ @@ -585,22 +481,18 @@ describe('PageSelector', () => { canPreview: true, }) - // Act render(<PageSelector {...props} />) fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) - // Assert expect(mockOnPreview).toHaveBeenCalledWith('page-1') }) it('should not throw when onPreview is undefined', () => { - // Arrange const props = createDefaultProps({ onPreview: undefined, canPreview: true, }) - // Act & Assert expect(() => { render(<PageSelector {...props} />) fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) @@ -610,7 +502,6 @@ describe('PageSelector', () => { describe('currentCredentialId prop', () => { it('should reset dataList when currentCredentialId changes', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), ] @@ -620,7 +511,6 @@ describe('PageSelector', () => { currentCredentialId: 'cred-1', }) - // Act const { rerender } = render(<PageSelector {...props} />) // Assert - Initial render @@ -635,19 +525,15 @@ describe('PageSelector', () => { }) }) - // ========================================== // State Management and Updates - // ========================================== describe('State Management and Updates', () => { it('should initialize dataList with root level pages', () => { - // Arrange const { list, pagesMap, rootPage, childPage1 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Assert - Only root level page should be visible initially @@ -657,14 +543,12 @@ describe('PageSelector', () => { }) it('should update dataList when expanding a page with children', () => { - // Arrange const { list, pagesMap, rootPage, childPage1, childPage2 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Find and click the expand arrow (uses hover:bg-components-button-ghost-bg-hover class) @@ -672,14 +556,12 @@ describe('PageSelector', () => { if (arrowButton) fireEvent.click(arrowButton) - // Assert expect(screen.getByText(rootPage.page_name)).toBeInTheDocument() expect(screen.getByText(childPage1.page_name)).toBeInTheDocument() expect(screen.getByText(childPage2.page_name)).toBeInTheDocument() }) it('should maintain currentPreviewPageId state', () => { - // Arrange const mockOnPreview = vi.fn() const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), @@ -692,17 +574,14 @@ describe('PageSelector', () => { canPreview: true, }) - // Act render(<PageSelector {...props} />) const previewButtons = screen.getAllByText('common.dataSource.notion.selector.preview') fireEvent.click(previewButtons[0]) - // Assert expect(mockOnPreview).toHaveBeenCalledWith('page-1') }) it('should use searchDataList when searchValue is present', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Apple' }), createMockPage({ page_id: 'page-2', page_name: 'Banana' }), @@ -713,7 +592,6 @@ describe('PageSelector', () => { searchValue: 'Apple', }) - // Act render(<PageSelector {...props} />) // Assert - Only pages matching search should be visible @@ -723,12 +601,9 @@ describe('PageSelector', () => { }) }) - // ========================================== // Side Effects and Cleanup - // ========================================== describe('Side Effects and Cleanup', () => { it('should reinitialize dataList when currentCredentialId changes', () => { - // Arrange const pages = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] const props = createDefaultProps({ list: pages, @@ -736,7 +611,6 @@ describe('PageSelector', () => { currentCredentialId: 'cred-1', }) - // Act const { rerender } = render(<PageSelector {...props} />) expect(screen.getByText('Page 1')).toBeInTheDocument() @@ -748,14 +622,12 @@ describe('PageSelector', () => { }) it('should filter root pages correctly on initialization', () => { - // Arrange const { list, pagesMap, rootPage, childPage1 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Assert - Only root level pages visible @@ -764,7 +636,6 @@ describe('PageSelector', () => { }) it('should include pages whose parent is not in pagesMap', () => { - // Arrange const orphanPage = createMockPage({ page_id: 'orphan-page', page_name: 'Orphan Page', @@ -775,7 +646,6 @@ describe('PageSelector', () => { pagesMap: createMockPagesMap([orphanPage]), }) - // Act render(<PageSelector {...props} />) // Assert - Orphan page should be visible at root level @@ -783,19 +653,15 @@ describe('PageSelector', () => { }) }) - // ========================================== // Callback Stability and Memoization - // ========================================== describe('Callback Stability and Memoization', () => { it('should have stable handleToggle that expands children', () => { - // Arrange const { list, pagesMap, childPage1, childPage2 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Find expand arrow for root page (has RiArrowRightSLine icon) @@ -809,14 +675,12 @@ describe('PageSelector', () => { }) it('should have stable handleToggle that collapses descendants', () => { - // Arrange const { list, pagesMap, childPage1, childPage2 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // First expand @@ -833,7 +697,6 @@ describe('PageSelector', () => { }) it('should have stable handleCheck that adds page and descendants to selection', () => { - // Arrange const mockOnSelect = vi.fn() const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ @@ -844,7 +707,6 @@ describe('PageSelector', () => { isMultipleChoice: true, }) - // Act render(<PageSelector {...props} />) // Check the root page @@ -857,7 +719,6 @@ describe('PageSelector', () => { }) it('should have stable handleCheck that removes page and descendants from selection', () => { - // Arrange const mockOnSelect = vi.fn() const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ @@ -868,7 +729,6 @@ describe('PageSelector', () => { isMultipleChoice: true, }) - // Act render(<PageSelector {...props} />) // Uncheck the root page @@ -879,7 +739,6 @@ describe('PageSelector', () => { }) it('should have stable handlePreview that updates currentPreviewPageId', () => { - // Arrange const mockOnPreview = vi.fn() const page = createMockPage({ page_id: 'preview-page' }) const props = createDefaultProps({ @@ -889,28 +748,22 @@ describe('PageSelector', () => { canPreview: true, }) - // Act render(<PageSelector {...props} />) fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) - // Assert expect(mockOnPreview).toHaveBeenCalledWith('preview-page') }) }) - // ========================================== // Memoization Logic and Dependencies - // ========================================== describe('Memoization Logic and Dependencies', () => { it('should compute listMapWithChildrenAndDescendants correctly', () => { - // Arrange const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Assert - Tree structure should be built (verified by expand functionality) @@ -919,14 +772,12 @@ describe('PageSelector', () => { }) it('should recompute listMapWithChildrenAndDescendants when list changes', () => { - // Arrange const initialList = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] const props = createDefaultProps({ list: initialList, pagesMap: createMockPagesMap(initialList), }) - // Act const { rerender } = render(<PageSelector {...props} />) expect(screen.getByText('Page 1')).toBeInTheDocument() @@ -937,20 +788,17 @@ describe('PageSelector', () => { ] rerender(<PageSelector {...props} list={newList} pagesMap={createMockPagesMap(newList)} />) - // Assert expect(screen.getByText('Page 1')).toBeInTheDocument() // Page 2 won't show because dataList state hasn't updated (only resets on credentialId change) }) it('should recompute listMapWithChildrenAndDescendants when pagesMap changes', () => { - // Arrange const initialList = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] const props = createDefaultProps({ list: initialList, pagesMap: createMockPagesMap(initialList), }) - // Act const { rerender } = render(<PageSelector {...props} />) // Update pagesMap @@ -965,39 +813,31 @@ describe('PageSelector', () => { }) it('should handle empty list in memoization', () => { - // Arrange const props = createDefaultProps({ list: [], pagesMap: {}, }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions and Event Handlers', () => { it('should toggle expansion when clicking arrow button', () => { - // Arrange const { list, pagesMap, childPage1 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Initially children are hidden expect(screen.queryByText(childPage1.page_name)).not.toBeInTheDocument() - // Click to expand const expandArrow = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') if (expandArrow) fireEvent.click(expandArrow) @@ -1007,23 +847,19 @@ describe('PageSelector', () => { }) it('should check/uncheck page when clicking checkbox', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect, checkedIds: new Set(), }) - // Act render(<PageSelector {...props} />) fireEvent.click(getCheckbox()) - // Assert expect(mockOnSelect).toHaveBeenCalled() }) it('should select radio when clicking in single choice mode', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect, @@ -1031,16 +867,13 @@ describe('PageSelector', () => { checkedIds: new Set(), }) - // Act render(<PageSelector {...props} />) fireEvent.click(getRadio()) - // Assert expect(mockOnSelect).toHaveBeenCalled() }) it('should clear previous selection in single choice mode', () => { - // Arrange const mockOnSelect = vi.fn() const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), @@ -1054,7 +887,6 @@ describe('PageSelector', () => { checkedIds: new Set(['page-1']), }) - // Act render(<PageSelector {...props} />) const radios = getAllRadios() fireEvent.click(radios[1]) // Click on page-2 @@ -1067,23 +899,19 @@ describe('PageSelector', () => { }) it('should trigger preview when clicking preview button', () => { - // Arrange const mockOnPreview = vi.fn() const props = createDefaultProps({ onPreview: mockOnPreview, canPreview: true, }) - // Act render(<PageSelector {...props} />) fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) - // Assert expect(mockOnPreview).toHaveBeenCalledWith('page-1') }) it('should not cascade selection in search mode', () => { - // Arrange const mockOnSelect = vi.fn() const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ @@ -1095,7 +923,6 @@ describe('PageSelector', () => { isMultipleChoice: true, }) - // Act render(<PageSelector {...props} />) fireEvent.click(getCheckbox()) @@ -1107,33 +934,25 @@ describe('PageSelector', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty list', () => { - // Arrange const props = createDefaultProps({ list: [], pagesMap: {}, }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() }) it('should handle null page_icon', () => { - // Arrange const page = createMockPage({ page_icon: null }) const props = createDefaultProps({ list: [page], pagesMap: createMockPagesMap([page]), }) - // Act render(<PageSelector {...props} />) // Assert - NotionIcon renders svg (RiFileTextLine) when page_icon is null @@ -1142,7 +961,6 @@ describe('PageSelector', () => { }) it('should handle page_icon with all properties', () => { - // Arrange const page = createMockPage({ page_icon: { type: 'emoji', url: null, emoji: '📄' }, }) @@ -1151,7 +969,6 @@ describe('PageSelector', () => { pagesMap: createMockPagesMap([page]), }) - // Act render(<PageSelector {...props} />) // Assert - NotionIcon renders the emoji @@ -1159,48 +976,38 @@ describe('PageSelector', () => { }) it('should handle empty searchValue correctly', () => { - // Arrange const props = createDefaultProps({ searchValue: '' }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByTestId('virtual-list')).toBeInTheDocument() }) it('should handle special characters in page name', () => { - // Arrange const page = createMockPage({ page_name: 'Test <script>alert("xss")</script>' }) const props = createDefaultProps({ list: [page], pagesMap: createMockPagesMap([page]), }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('Test <script>alert("xss")</script>')).toBeInTheDocument() }) it('should handle unicode characters in page name', () => { - // Arrange const page = createMockPage({ page_name: 'æ”‹èŻ•éĄ”éą 🔍 проĐČДт' }) const props = createDefaultProps({ list: [page], pagesMap: createMockPagesMap([page]), }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('æ”‹èŻ•éĄ”éą 🔍 проĐČДт')).toBeInTheDocument() }) it('should handle very long page names', () => { - // Arrange const longName = 'A'.repeat(500) const page = createMockPage({ page_name: longName }) const props = createDefaultProps({ @@ -1208,10 +1015,8 @@ describe('PageSelector', () => { pagesMap: createMockPagesMap([page]), }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) @@ -1235,7 +1040,6 @@ describe('PageSelector', () => { pagesMap: createMockPagesMap(pages), }) - // Act render(<PageSelector {...props} />) // Assert - Only root level visible @@ -1257,7 +1061,6 @@ describe('PageSelector', () => { pagesMap, }) - // Act render(<PageSelector {...props} />) // Assert - Should render the orphan page at root level @@ -1265,39 +1068,31 @@ describe('PageSelector', () => { }) it('should handle empty checkedIds Set', () => { - // Arrange const props = createDefaultProps({ checkedIds: new Set() }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxChecked(checkbox)).toBe(false) }) it('should handle empty disabledValue Set', () => { - // Arrange const props = createDefaultProps({ disabledValue: new Set() }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxDisabled(checkbox)).toBe(false) }) it('should handle undefined onPreview gracefully', () => { - // Arrange const props = createDefaultProps({ onPreview: undefined, canPreview: true, }) - // Act render(<PageSelector {...props} />) // Assert - Click should not throw @@ -1307,14 +1102,12 @@ describe('PageSelector', () => { }) it('should handle page without descendants correctly', () => { - // Arrange const leafPage = createMockPage({ page_id: 'leaf', page_name: 'Leaf Page' }) const props = createDefaultProps({ list: [leafPage], pagesMap: createMockPagesMap([leafPage]), }) - // Act render(<PageSelector {...props} />) // Assert - No expand arrow for leaf pages @@ -1323,9 +1116,7 @@ describe('PageSelector', () => { }) }) - // ========================================== // All Prop Variations - // ========================================== describe('Prop Variations', () => { it.each([ [{ canPreview: true, isMultipleChoice: true }], @@ -1333,13 +1124,10 @@ describe('PageSelector', () => { [{ canPreview: false, isMultipleChoice: true }], [{ canPreview: false, isMultipleChoice: false }], ])('should render correctly with props %o', (propVariation) => { - // Arrange const props = createDefaultProps(propVariation) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByTestId('virtual-list')).toBeInTheDocument() if (propVariation.canPreview) expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() @@ -1353,7 +1141,6 @@ describe('PageSelector', () => { }) it('should handle all default prop values', () => { - // Arrange const minimalProps: PageSelectorProps = { checkedIds: new Set(), disabledValue: new Set(), @@ -1366,7 +1153,6 @@ describe('PageSelector', () => { // isMultipleChoice defaults to true } - // Act render(<PageSelector {...minimalProps} />) // Assert - Defaults should be applied @@ -1375,12 +1161,9 @@ describe('PageSelector', () => { }) }) - // ========================================== // Utils Function Tests - // ========================================== describe('Utils - recursivePushInParentDescendants', () => { it('should build tree structure for simple parent-child relationship', () => { - // Arrange const parent = createMockPage({ page_id: 'parent', page_name: 'Parent', parent_id: 'root' }) const child = createMockPage({ page_id: 'child', page_name: 'Child', parent_id: 'parent' }) const pagesMap = createMockPagesMap([parent, child]) @@ -1396,10 +1179,8 @@ describe('PageSelector', () => { } listTreeMap[child.page_id] = childEntry - // Act recursivePushInParentDescendants(pagesMap, listTreeMap, childEntry, childEntry) - // Assert expect(listTreeMap.parent).toBeDefined() expect(listTreeMap.parent.children.has('child')).toBe(true) expect(listTreeMap.parent.descendants.has('child')).toBe(true) @@ -1408,7 +1189,6 @@ describe('PageSelector', () => { }) it('should handle root level pages', () => { - // Arrange const rootPage = createMockPage({ page_id: 'root-page', parent_id: 'root' }) const pagesMap = createMockPagesMap([rootPage]) const listTreeMap: NotionPageTreeMap = {} @@ -1422,7 +1202,6 @@ describe('PageSelector', () => { } listTreeMap[rootPage.page_id] = rootEntry - // Act recursivePushInParentDescendants(pagesMap, listTreeMap, rootEntry, rootEntry) // Assert - No parent should be created for root level @@ -1432,7 +1211,6 @@ describe('PageSelector', () => { }) it('should handle missing parent in pagesMap', () => { - // Arrange const orphan = createMockPage({ page_id: 'orphan', parent_id: 'missing-parent' }) const pagesMap = createMockPagesMap([orphan]) const listTreeMap: NotionPageTreeMap = {} @@ -1446,7 +1224,6 @@ describe('PageSelector', () => { } listTreeMap[orphan.page_id] = orphanEntry - // Act recursivePushInParentDescendants(pagesMap, listTreeMap, orphanEntry, orphanEntry) // Assert - Should not create parent entry for missing parent @@ -1454,7 +1231,6 @@ describe('PageSelector', () => { }) it('should handle null parent_id', () => { - // Arrange const page = createMockPage({ page_id: 'page', parent_id: '' }) const pagesMap = createMockPagesMap([page]) const listTreeMap: NotionPageTreeMap = {} @@ -1468,7 +1244,6 @@ describe('PageSelector', () => { } listTreeMap[page.page_id] = pageEntry - // Act recursivePushInParentDescendants(pagesMap, listTreeMap, pageEntry, pageEntry) // Assert - Early return, no changes @@ -1513,7 +1288,6 @@ describe('PageSelector', () => { // Act - Process from leaf to root recursivePushInParentDescendants(pagesMap, listTreeMap, l2Entry, l2Entry) - // Assert expect(l2Entry.depth).toBe(2) expect(l2Entry.ancestors).toEqual(['Level 0', 'Level 1']) expect(listTreeMap.l1.children.has('l2')).toBe(true) @@ -1521,7 +1295,6 @@ describe('PageSelector', () => { }) it('should update existing parent entry', () => { - // Arrange const parent = createMockPage({ page_id: 'parent', page_name: 'Parent', parent_id: 'root' }) const child1 = createMockPage({ page_id: 'child1', parent_id: 'parent' }) const child2 = createMockPage({ page_id: 'child2', parent_id: 'parent' }) @@ -1546,7 +1319,6 @@ describe('PageSelector', () => { } listTreeMap[child2.page_id] = child2Entry - // Act recursivePushInParentDescendants(pagesMap, listTreeMap, child2Entry, child2Entry) // Assert - Should add child2 to existing parent @@ -1557,12 +1329,9 @@ describe('PageSelector', () => { }) }) - // ========================================== // Item Component Integration Tests - // ========================================== describe('Item Component Integration', () => { it('should render item with correct styling for preview state', () => { - // Arrange const page = createMockPage({ page_id: 'page-1', page_name: 'Test Page' }) const props = createDefaultProps({ list: [page], @@ -1570,10 +1339,8 @@ describe('PageSelector', () => { canPreview: true, }) - // Act render(<PageSelector {...props} />) - // Click preview to set currentPreviewPageId fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) // Assert - Item should have preview styling class @@ -1582,14 +1349,12 @@ describe('PageSelector', () => { }) it('should show arrow for pages with children', () => { - // Arrange const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Assert - Root page should have expand arrow @@ -1598,14 +1363,12 @@ describe('PageSelector', () => { }) it('should not show arrow for leaf pages', () => { - // Arrange const leafPage = createMockPage({ page_id: 'leaf', page_name: 'Leaf' }) const props = createDefaultProps({ list: [leafPage], pagesMap: createMockPagesMap([leafPage]), }) - // Act render(<PageSelector {...props} />) // Assert - No expand arrow for leaf pages @@ -1614,7 +1377,6 @@ describe('PageSelector', () => { }) it('should hide arrows in search mode', () => { - // Arrange const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ list, @@ -1622,7 +1384,6 @@ describe('PageSelector', () => { searchValue: 'Root', }) - // Act render(<PageSelector {...props} />) // Assert - No expand arrows in search mode (renderArrow returns null when searchValue) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts new file mode 100644 index 0000000000..601dc2f5bf --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts @@ -0,0 +1,100 @@ +import type { NotionPageTreeItem, NotionPageTreeMap } from '../index' +import type { DataSourceNotionPageMap } from '@/models/common' +import { describe, expect, it } from 'vitest' +import { recursivePushInParentDescendants } from '../utils' + +const makePageEntry = (overrides: Partial<NotionPageTreeItem>): NotionPageTreeItem => ({ + page_icon: null, + page_id: '', + page_name: '', + parent_id: '', + type: 'page', + is_bound: false, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + ...overrides, +}) + +describe('recursivePushInParentDescendants', () => { + it('should add child to parent descendants', () => { + const pagesMap = { + parent1: { page_id: 'parent1', parent_id: 'root', page_name: 'Parent' }, + child1: { page_id: 'child1', parent_id: 'parent1', page_name: 'Child' }, + } as unknown as DataSourceNotionPageMap + + const listTreeMap: NotionPageTreeMap = { + child1: makePageEntry({ page_id: 'child1', parent_id: 'parent1', page_name: 'Child' }), + } + + recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child1, listTreeMap.child1) + + expect(listTreeMap.parent1).toBeDefined() + expect(listTreeMap.parent1.children.has('child1')).toBe(true) + expect(listTreeMap.parent1.descendants.has('child1')).toBe(true) + }) + + it('should recursively populate ancestors for deeply nested items', () => { + const pagesMap = { + grandparent: { page_id: 'grandparent', parent_id: 'root', page_name: 'Grandparent' }, + parent: { page_id: 'parent', parent_id: 'grandparent', page_name: 'Parent' }, + child: { page_id: 'child', parent_id: 'parent', page_name: 'Child' }, + } as unknown as DataSourceNotionPageMap + + const listTreeMap: NotionPageTreeMap = { + parent: makePageEntry({ page_id: 'parent', parent_id: 'grandparent', page_name: 'Parent' }), + child: makePageEntry({ page_id: 'child', parent_id: 'parent', page_name: 'Child' }), + } + + recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child, listTreeMap.child) + + expect(listTreeMap.child.depth).toBe(2) + expect(listTreeMap.child.ancestors).toContain('Grandparent') + expect(listTreeMap.child.ancestors).toContain('Parent') + }) + + it('should do nothing for root parent', () => { + const pagesMap = { + root_child: { page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' }, + } as unknown as DataSourceNotionPageMap + + const listTreeMap: NotionPageTreeMap = { + root_child: makePageEntry({ page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' }), + } + + recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.root_child, listTreeMap.root_child) + + // No new entries should be added since parent is root + expect(Object.keys(listTreeMap)).toEqual(['root_child']) + }) + + it('should handle missing parent_id gracefully', () => { + const pagesMap = {} as DataSourceNotionPageMap + const current = makePageEntry({ page_id: 'orphan', parent_id: undefined as unknown as string }) + const listTreeMap: NotionPageTreeMap = { orphan: current } + + // Should not throw + recursivePushInParentDescendants(pagesMap, listTreeMap, current, current) + expect(listTreeMap.orphan.depth).toBe(0) + }) + + it('should add to existing parent entry when parent already in tree', () => { + const pagesMap = { + parent: { page_id: 'parent', parent_id: 'root', page_name: 'Parent' }, + child1: { page_id: 'child1', parent_id: 'parent', page_name: 'Child1' }, + child2: { page_id: 'child2', parent_id: 'parent', page_name: 'Child2' }, + } as unknown as DataSourceNotionPageMap + + const listTreeMap: NotionPageTreeMap = { + parent: makePageEntry({ page_id: 'parent', parent_id: 'root', children: new Set(['child1']), descendants: new Set(['child1']), page_name: 'Parent' }), + child2: makePageEntry({ page_id: 'child2', parent_id: 'parent', page_name: 'Child2' }), + } + + recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child2, listTreeMap.child2) + + expect(listTreeMap.parent.children.has('child2')).toBe(true) + expect(listTreeMap.parent.descendants.has('child2')).toBe(true) + expect(listTreeMap.parent.children.has('child1')).toBe(true) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/header.spec.tsx new file mode 100644 index 0000000000..c7a61dfdad --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/header.spec.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Header from '../header' + +describe('OnlineDriveHeader', () => { + const defaultProps = { + docTitle: 'S3 Guide', + docLink: 'https://docs.aws.com/s3', + onClickConfiguration: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render doc link with title', () => { + render(<Header {...defaultProps} />) + const link = screen.getByText('S3 Guide').closest('a') + expect(link).toHaveAttribute('href', 'https://docs.aws.com/s3') + expect(link).toHaveAttribute('target', '_blank') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx index fb7fef1cbb..1721b72e1c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx @@ -5,15 +5,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline' -import Header from './header' -import OnlineDrive from './index' -import { convertOnlineDriveData, isBucketListInitiation, isFile } from './utils' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import Header from '../header' +import OnlineDrive from '../index' +import { convertOnlineDriveData, isBucketListInitiation, isFile } from '../utils' // Mock useDocLink - context hook requires mocking const mockDocLink = vi.fn((path?: string) => `https://docs.example.com${path || ''}`) @@ -24,13 +18,13 @@ vi.mock('@/context/i18n', () => ({ // Mock dataset-detail context - context provider requires mocking let mockPipelineId: string | undefined = 'pipeline-123' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (s: any) => any) => selector({ dataset: { pipeline_id: mockPipelineId } }), + useDatasetDetailContextWithSelector: (selector: (s: Record<string, unknown>) => unknown) => selector({ dataset: { pipeline_id: mockPipelineId } }), })) // Mock modal context - context provider requires mocking const mockSetShowAccountSettingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: (selector: (s: any) => any) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), + useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), })) // Mock ssePost - API service requires mocking @@ -51,7 +45,6 @@ vi.mock('@/service/use-datasource', () => ({ useGetDataSourceAuth: mockUseGetDataSourceAuth, })) -// Mock Toast const { mockToastNotify } = vi.hoisted(() => ({ mockToastNotify: vi.fn(), })) @@ -66,7 +59,7 @@ vi.mock('@/app/components/base/toast', () => ({ // Mock store state const mockStoreState = { - nextPageParameters: {} as Record<string, any>, + nextPageParameters: {} as Record<string, unknown>, breadcrumbs: [] as string[], prefix: [] as string[], keywords: '', @@ -88,48 +81,48 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../store', () => ({ - useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState), +vi.mock('../../store', () => ({ + useDataSourceStoreWithSelector: (selector: (s: Record<string, unknown>) => unknown) => selector(mockStoreState as unknown as Record<string, unknown>), useDataSourceStore: () => mockDataSourceStore, })) // Mock Header component -vi.mock('../base/header', () => ({ - default: (props: any) => ( +vi.mock('../../base/header', () => ({ + default: (props: Record<string, unknown>) => ( <div data-testid="header"> - <span data-testid="header-doc-title">{props.docTitle}</span> - <span data-testid="header-doc-link">{props.docLink}</span> - <span data-testid="header-plugin-name">{props.pluginName}</span> - <span data-testid="header-credential-id">{props.currentCredentialId}</span> - <button data-testid="header-config-btn" onClick={props.onClickConfiguration}>Configure</button> - <button data-testid="header-credential-change" onClick={() => props.onCredentialChange('new-cred-id')}>Change Credential</button> - <span data-testid="header-credentials-count">{props.credentials?.length || 0}</span> + <span data-testid="header-doc-title">{props.docTitle as string}</span> + <span data-testid="header-doc-link">{props.docLink as string}</span> + <span data-testid="header-plugin-name">{props.pluginName as string}</span> + <span data-testid="header-credential-id">{props.currentCredentialId as string}</span> + <button data-testid="header-config-btn" onClick={props.onClickConfiguration as React.MouseEventHandler}>Configure</button> + <button data-testid="header-credential-change" onClick={() => (props.onCredentialChange as (id: string) => void)('new-cred-id')}>Change Credential</button> + <span data-testid="header-credentials-count">{(props.credentials as unknown[] | undefined)?.length || 0}</span> </div> ), })) // Mock FileList component -vi.mock('./file-list', () => ({ - default: (props: any) => ( +vi.mock('../file-list', () => ({ + default: (props: Record<string, unknown>) => ( <div data-testid="file-list"> - <span data-testid="file-list-count">{props.fileList?.length || 0}</span> - <span data-testid="file-list-selected-count">{props.selectedFileIds?.length || 0}</span> - <span data-testid="file-list-breadcrumbs">{props.breadcrumbs?.join('/') || ''}</span> - <span data-testid="file-list-keywords">{props.keywords}</span> - <span data-testid="file-list-bucket">{props.bucket}</span> + <span data-testid="file-list-count">{(props.fileList as unknown[] | undefined)?.length || 0}</span> + <span data-testid="file-list-selected-count">{(props.selectedFileIds as unknown[] | undefined)?.length || 0}</span> + <span data-testid="file-list-breadcrumbs">{(props.breadcrumbs as string[] | undefined)?.join('/') || ''}</span> + <span data-testid="file-list-keywords">{props.keywords as string}</span> + <span data-testid="file-list-bucket">{props.bucket as string}</span> <span data-testid="file-list-loading">{String(props.isLoading)}</span> <span data-testid="file-list-is-in-pipeline">{String(props.isInPipeline)}</span> <span data-testid="file-list-support-batch">{String(props.supportBatchUpload)}</span> <input data-testid="file-list-search-input" - onChange={e => props.updateKeywords(e.target.value)} + onChange={e => (props.updateKeywords as (v: string) => void)(e.target.value)} /> - <button data-testid="file-list-reset-keywords" onClick={props.resetKeywords}>Reset</button> + <button data-testid="file-list-reset-keywords" onClick={props.resetKeywords as React.MouseEventHandler}>Reset</button> <button data-testid="file-list-select-file" onClick={() => { const file: OnlineDriveFile = { id: 'file-1', name: 'test.txt', type: OnlineDriveFileType.file } - props.handleSelectFile(file) + ;(props.handleSelectFile as (f: OnlineDriveFile) => void)(file) }} > Select File @@ -138,7 +131,7 @@ vi.mock('./file-list', () => ({ data-testid="file-list-select-bucket" onClick={() => { const file: OnlineDriveFile = { id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket } - props.handleSelectFile(file) + ;(props.handleSelectFile as (f: OnlineDriveFile) => void)(file) }} > Select Bucket @@ -147,7 +140,7 @@ vi.mock('./file-list', () => ({ data-testid="file-list-open-folder" onClick={() => { const file: OnlineDriveFile = { id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder } - props.handleOpenFolder(file) + ;(props.handleOpenFolder as (f: OnlineDriveFile) => void)(file) }} > Open Folder @@ -156,7 +149,7 @@ vi.mock('./file-list', () => ({ data-testid="file-list-open-bucket" onClick={() => { const file: OnlineDriveFile = { id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket } - props.handleOpenFolder(file) + ;(props.handleOpenFolder as (f: OnlineDriveFile) => void)(file) }} > Open Bucket @@ -165,7 +158,7 @@ vi.mock('./file-list', () => ({ data-testid="file-list-open-file" onClick={() => { const file: OnlineDriveFile = { id: 'file-1', name: 'test.txt', type: OnlineDriveFileType.file } - props.handleOpenFolder(file) + ;(props.handleOpenFolder as (f: OnlineDriveFile) => void)(file) }} > Open File @@ -174,9 +167,6 @@ vi.mock('./file-list', () => ({ ), })) -// ========================================== -// Test Data Builders -// ========================================== const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ title: 'Test Node', plugin_id: 'plugin-123', @@ -218,9 +208,6 @@ const createDefaultProps = (overrides?: Partial<OnlineDriveProps>): OnlineDriveP ...overrides, }) -// ========================================== -// Helper Functions -// ========================================== const resetMockStoreState = () => { mockStoreState.nextPageParameters = {} mockStoreState.breadcrumbs = [] @@ -241,9 +228,6 @@ const resetMockStoreState = () => { mockStoreState.setHasBucket = vi.fn() } -// ========================================== -// Test Suites -// ========================================== describe('OnlineDrive', () => { beforeEach(() => { vi.clearAllMocks() @@ -263,40 +247,30 @@ describe('OnlineDrive', () => { mockGetState.mockReturnValue(mockStoreState) }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header')).toBeInTheDocument() expect(screen.getByTestId('file-list')).toBeInTheDocument() }) it('should render Header with correct props', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-123' const props = createDefaultProps({ nodeData: createMockNodeData({ datasource_label: 'My Online Drive' }), }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header-doc-title')).toHaveTextContent('Docs') expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('My Online Drive') expect(screen.getByTestId('header-credential-id')).toHaveTextContent('cred-123') }) it('should render FileList with correct props', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.keywords = 'search-term' mockStoreState.breadcrumbs = ['folder1', 'folder2'] @@ -308,10 +282,8 @@ describe('OnlineDrive', () => { ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list')).toBeInTheDocument() expect(screen.getByTestId('file-list-keywords')).toHaveTextContent('search-term') expect(screen.getByTestId('file-list-breadcrumbs')).toHaveTextContent('folder1/folder2') @@ -320,31 +292,23 @@ describe('OnlineDrive', () => { }) it('should pass docLink with correct path to Header', () => { - // Arrange const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(mockDocLink).toHaveBeenCalledWith('/use-dify/knowledge/knowledge-pipeline/authorize-data-source') }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('nodeId prop', () => { it('should use nodeId in datasourceNodeRunURL for non-pipeline mode', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ nodeId: 'custom-node-id', isInPipeline: false, }) - // Act render(<OnlineDrive {...props} />) // Assert - ssePost should be called with correct URL @@ -358,14 +322,12 @@ describe('OnlineDrive', () => { }) it('should use nodeId in datasourceNodeRunURL for pipeline mode', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ nodeId: 'custom-node-id', isInPipeline: true, }) - // Act render(<OnlineDrive {...props} />) // Assert - ssePost should be called with correct URL for draft @@ -381,17 +343,14 @@ describe('OnlineDrive', () => { describe('nodeData prop', () => { it('should pass plugin_id and provider_name to useGetDataSourceAuth', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'my-plugin-id', provider_name: 'my-provider', }) const props = createDefaultProps({ nodeData }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId: 'my-plugin-id', provider: 'my-provider', @@ -399,30 +358,24 @@ describe('OnlineDrive', () => { }) it('should pass datasource_label to Header as pluginName', () => { - // Arrange const nodeData = createMockNodeData({ datasource_label: 'Custom Online Drive', }) const props = createDefaultProps({ nodeData }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('Custom Online Drive') }) }) describe('isInPipeline prop', () => { it('should use draft URL when isInPipeline is true', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ isInPipeline: true }) - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( expect.stringContaining('/workflows/draft/'), @@ -433,14 +386,11 @@ describe('OnlineDrive', () => { }) it('should use published URL when isInPipeline is false', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ isInPipeline: false }) - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( expect.stringContaining('/workflows/published/'), @@ -451,37 +401,28 @@ describe('OnlineDrive', () => { }) it('should pass isInPipeline to FileList', () => { - // Arrange const props = createDefaultProps({ isInPipeline: true }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-is-in-pipeline')).toHaveTextContent('true') }) }) describe('supportBatchUpload prop', () => { it('should pass supportBatchUpload true to FileList when supportBatchUpload is true', () => { - // Arrange const props = createDefaultProps({ supportBatchUpload: true }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-support-batch')).toHaveTextContent('true') }) it('should pass supportBatchUpload false to FileList when supportBatchUpload is false', () => { - // Arrange const props = createDefaultProps({ supportBatchUpload: false }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-support-batch')).toHaveTextContent('false') }) @@ -490,59 +431,45 @@ describe('OnlineDrive', () => { [false, 'false'], [undefined, 'true'], // Default value ])('should handle supportBatchUpload=%s correctly', (value, expected) => { - // Arrange const props = createDefaultProps({ supportBatchUpload: value }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-support-batch')).toHaveTextContent(expected) }) }) describe('onCredentialChange prop', () => { it('should call onCredentialChange with credential id', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) - // Act render(<OnlineDrive {...props} />) fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) }) - // ========================================== - // State Management Tests - // ========================================== describe('State Management', () => { it('should fetch files on initial mount when fileList is empty', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should not fetch files on initial mount when fileList is not empty', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Assert - Wait a bit to ensure no call is made @@ -551,11 +478,9 @@ describe('OnlineDrive', () => { }) it('should not fetch files when currentCredentialId is empty', async () => { - // Arrange mockStoreState.currentCredentialId = '' const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Assert - Wait a bit to ensure no call is made @@ -564,24 +489,20 @@ describe('OnlineDrive', () => { }) it('should show loading state during fetch', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation(() => { // Never resolves to keep loading state }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(screen.getByTestId('file-list-loading')).toHaveTextContent('true') }) }) it('should update file list on successful fetch', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockFiles = [ { id: 'file-1', name: 'file1.txt', type: 'file' as const }, @@ -600,17 +521,14 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalled() }) }) it('should show error toast on fetch error', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const errorMessage = 'Failed to fetch files' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -620,10 +538,8 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -633,12 +549,9 @@ describe('OnlineDrive', () => { }) }) - // ========================================== // Memoization Logic and Dependencies Tests - // ========================================== describe('Memoization Logic', () => { it('should filter files by keywords', () => { - // Arrange mockStoreState.keywords = 'test' mockStoreState.onlineDriveFileList = [ createMockOnlineDriveFile({ id: '1', name: 'test-file.txt' }), @@ -647,7 +560,6 @@ describe('OnlineDrive', () => { ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Assert - filteredOnlineDriveFileList should have 2 items matching 'test' @@ -655,7 +567,6 @@ describe('OnlineDrive', () => { }) it('should return all files when keywords is empty', () => { - // Arrange mockStoreState.keywords = '' mockStoreState.onlineDriveFileList = [ createMockOnlineDriveFile({ id: '1', name: 'file1.txt' }), @@ -664,15 +575,12 @@ describe('OnlineDrive', () => { ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-count')).toHaveTextContent('3') }) it('should filter files case-insensitively', () => { - // Arrange mockStoreState.keywords = 'TEST' mockStoreState.onlineDriveFileList = [ createMockOnlineDriveFile({ id: '1', name: 'test-file.txt' }), @@ -681,109 +589,83 @@ describe('OnlineDrive', () => { ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-count')).toHaveTextContent('2') }) }) - // ========================================== // Callback Stability and Memoization - // ========================================== describe('Callback Stability and Memoization', () => { it('should have stable handleSetting callback', () => { - // Arrange const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }) it('should have stable updateKeywords that updates store', () => { - // Arrange const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.change(screen.getByTestId('file-list-search-input'), { target: { value: 'new-keyword' } }) - // Assert expect(mockStoreState.setKeywords).toHaveBeenCalledWith('new-keyword') }) it('should have stable resetKeywords that clears keywords', () => { - // Arrange mockStoreState.keywords = 'old-keyword' const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-reset-keywords')) - // Assert expect(mockStoreState.setKeywords).toHaveBeenCalledWith('') }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions', () => { describe('File Selection', () => { it('should toggle file selection on file click', () => { - // Arrange mockStoreState.selectedFileIds = [] const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-select-file')) - // Assert expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith(['file-1']) }) it('should deselect file if already selected', () => { - // Arrange mockStoreState.selectedFileIds = ['file-1'] const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-select-file')) - // Assert expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) }) it('should not select bucket type items', () => { - // Arrange mockStoreState.selectedFileIds = [] const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-select-bucket')) - // Assert expect(mockStoreState.setSelectedFileIds).not.toHaveBeenCalled() }) it('should limit selection to one file when supportBatchUpload is false', () => { - // Arrange mockStoreState.selectedFileIds = ['existing-file'] const props = createDefaultProps({ supportBatchUpload: false }) render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-select-file')) // Assert - Should not add new file because there's already one selected @@ -791,31 +673,25 @@ describe('OnlineDrive', () => { }) it('should allow multiple selections when supportBatchUpload is true', () => { - // Arrange mockStoreState.selectedFileIds = ['existing-file'] const props = createDefaultProps({ supportBatchUpload: true }) render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-select-file')) - // Assert expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith(['existing-file', 'file-1']) }) }) describe('Folder Navigation', () => { it('should open folder and update breadcrumbs/prefix', () => { - // Arrange mockStoreState.breadcrumbs = [] mockStoreState.prefix = [] const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-open-folder')) - // Assert expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['my-folder']) @@ -823,24 +699,19 @@ describe('OnlineDrive', () => { }) it('should open bucket and set bucket name', () => { - // Arrange const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-open-bucket')) - // Assert expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockStoreState.setBucket).toHaveBeenCalledWith('my-bucket') }) it('should not navigate when opening a file', () => { - // Arrange const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-open-file')) // Assert - No navigation functions should be called @@ -852,29 +723,23 @@ describe('OnlineDrive', () => { describe('Credential Change', () => { it('should call onCredentialChange prop', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) describe('Configuration', () => { it('should open account setting modal on configuration click', () => { - // Arrange const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) @@ -882,12 +747,9 @@ describe('OnlineDrive', () => { }) }) - // ========================================== // Side Effects and Cleanup Tests - // ========================================== describe('Side Effects and Cleanup', () => { it('should fetch files when nextPageParameters changes after initial mount', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] const props = createDefaultProps() @@ -897,14 +759,12 @@ describe('OnlineDrive', () => { mockStoreState.nextPageParameters = { page: 2 } rerender(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should fetch files when prefix changes after initial mount', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] const props = createDefaultProps() @@ -914,14 +774,12 @@ describe('OnlineDrive', () => { mockStoreState.prefix = ['folder1'] rerender(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should fetch files when bucket changes after initial mount', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] const props = createDefaultProps() @@ -931,14 +789,12 @@ describe('OnlineDrive', () => { mockStoreState.bucket = 'new-bucket' rerender(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should fetch files when currentCredentialId changes after initial mount', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] const props = createDefaultProps() @@ -948,14 +804,12 @@ describe('OnlineDrive', () => { mockStoreState.currentCredentialId = 'cred-2' rerender(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should not fetch files concurrently (debounce)', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' let resolveFirst: () => void const firstPromise = new Promise<void>((resolve) => { @@ -971,7 +825,6 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Try to trigger another fetch while first is loading @@ -980,27 +833,21 @@ describe('OnlineDrive', () => { // Assert - Only one call should be made initially due to isLoadingRef guard expect(mockSsePost).toHaveBeenCalledTimes(1) - // Cleanup resolveFirst!() }) }) - // ========================================== // API Calls Mocking Tests - // ========================================== describe('API Calls', () => { it('should call ssePost with correct parameters', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.prefix = ['folder1'] mockStoreState.bucket = 'my-bucket' mockStoreState.nextPageParameters = { cursor: 'abc' } const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( expect.any(String), @@ -1025,7 +872,6 @@ describe('OnlineDrive', () => { }) it('should handle completed response and update store', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.breadcrumbs = ['folder1'] mockStoreState.bucket = 'my-bucket' @@ -1046,10 +892,8 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalled() expect(mockStoreState.setHasBucket).toHaveBeenCalledWith(true) @@ -1059,7 +903,6 @@ describe('OnlineDrive', () => { }) it('should handle error response and show toast', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const errorMessage = 'Access denied' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1069,10 +912,8 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -1082,45 +923,34 @@ describe('OnlineDrive', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty credentials list', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: { result: [] }, }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle undefined credentials data', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: undefined, }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle undefined pipelineId', async () => { - // Arrange mockPipelineId = undefined mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Assert - Should still attempt to call ssePost with undefined in URL @@ -1134,43 +964,33 @@ describe('OnlineDrive', () => { }) it('should handle empty file list', () => { - // Arrange mockStoreState.onlineDriveFileList = [] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-count')).toHaveTextContent('0') }) it('should handle empty breadcrumbs', () => { - // Arrange mockStoreState.breadcrumbs = [] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-breadcrumbs')).toHaveTextContent('') }) it('should handle empty bucket', () => { - // Arrange mockStoreState.bucket = '' const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-bucket')).toHaveTextContent('') }) it('should handle special characters in keywords', () => { - // Arrange mockStoreState.keywords = 'test.file[1]' mockStoreState.onlineDriveFileList = [ createMockOnlineDriveFile({ id: '1', name: 'test.file[1].txt' }), @@ -1178,7 +998,6 @@ describe('OnlineDrive', () => { ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Assert - Should find file with special characters @@ -1186,22 +1005,18 @@ describe('OnlineDrive', () => { }) it('should handle very long file names', () => { - // Arrange const longName = `${'a'.repeat(500)}.txt` mockStoreState.onlineDriveFileList = [ createMockOnlineDriveFile({ id: '1', name: longName }), ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-count')).toHaveTextContent('1') }) it('should handle bucket list initiation response', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.bucket = '' mockStoreState.prefix = [] @@ -1217,19 +1032,14 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockStoreState.setHasBucket).toHaveBeenCalledWith(true) }) }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { isInPipeline: true, supportBatchUpload: true }, @@ -1237,13 +1047,10 @@ describe('OnlineDrive', () => { { isInPipeline: false, supportBatchUpload: true }, { isInPipeline: false, supportBatchUpload: false }, ])('should render correctly with isInPipeline=%s and supportBatchUpload=%s', (propVariation) => { - // Arrange const props = createDefaultProps(propVariation) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header')).toBeInTheDocument() expect(screen.getByTestId('file-list')).toBeInTheDocument() expect(screen.getByTestId('file-list-is-in-pipeline')).toHaveTextContent(String(propVariation.isInPipeline)) @@ -1255,14 +1062,11 @@ describe('OnlineDrive', () => { { nodeId: 'node-b', expectedUrlPart: 'nodes/node-b/run' }, { nodeId: '123-456', expectedUrlPart: 'nodes/123-456/run' }, ])('should use correct URL for nodeId=%s', async ({ nodeId, expectedUrlPart }) => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ nodeId }) - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( expect.stringContaining(expectedUrlPart), @@ -1277,7 +1081,6 @@ describe('OnlineDrive', () => { { pluginId: 'plugin-b', providerName: 'provider-b' }, { pluginId: '', providerName: '' }, ])('should call useGetDataSourceAuth with pluginId=%s and providerName=%s', ({ pluginId, providerName }) => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ plugin_id: pluginId, @@ -1285,10 +1088,8 @@ describe('OnlineDrive', () => { }), }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId, provider: providerName, @@ -1297,9 +1098,7 @@ describe('OnlineDrive', () => { }) }) -// ========================================== // Header Component Tests -// ========================================== describe('Header', () => { const createHeaderProps = (overrides?: Partial<React.ComponentProps<typeof Header>>) => ({ onClickConfiguration: vi.fn(), @@ -1314,27 +1113,21 @@ describe('Header', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createHeaderProps() - // Act render(<Header {...props} />) - // Assert expect(screen.getByText('Documentation')).toBeInTheDocument() }) it('should render doc link with correct href', () => { - // Arrange const props = createHeaderProps({ docLink: 'https://custom-docs.com/path', docTitle: 'Custom Docs', }) - // Act render(<Header {...props} />) - // Assert const link = screen.getByRole('link') expect(link).toHaveAttribute('href', 'https://custom-docs.com/path') expect(link).toHaveAttribute('target', '_blank') @@ -1342,24 +1135,18 @@ describe('Header', () => { }) it('should render doc title text', () => { - // Arrange const props = createHeaderProps({ docTitle: 'My Documentation Title' }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByText('My Documentation Title')).toBeInTheDocument() }) it('should render configuration button', () => { - // Arrange const props = createHeaderProps() - // Act render(<Header {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) @@ -1372,13 +1159,10 @@ describe('Header', () => { 'Installation Guide', '', ])('should render docTitle="%s"', (docTitle) => { - // Arrange const props = createHeaderProps({ docTitle }) - // Act render(<Header {...props} />) - // Assert if (docTitle) expect(screen.getByText(docTitle)).toBeInTheDocument() }) @@ -1390,37 +1174,29 @@ describe('Header', () => { 'https://docs.example.com/path/to/page', '/relative/path', ])('should set href to "%s"', (docLink) => { - // Arrange const props = createHeaderProps({ docLink }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByRole('link')).toHaveAttribute('href', docLink) }) }) describe('onClickConfiguration prop', () => { it('should call onClickConfiguration when configuration icon is clicked', () => { - // Arrange const mockOnClickConfiguration = vi.fn() const props = createHeaderProps({ onClickConfiguration: mockOnClickConfiguration }) - // Act render(<Header {...props} />) const configIcon = screen.getByRole('button').querySelector('svg') fireEvent.click(configIcon!) - // Assert expect(mockOnClickConfiguration).toHaveBeenCalledTimes(1) }) it('should not throw when onClickConfiguration is undefined', () => { - // Arrange const props = createHeaderProps({ onClickConfiguration: undefined }) - // Act & Assert expect(() => render(<Header {...props} />)).not.toThrow() }) }) @@ -1428,34 +1204,25 @@ describe('Header', () => { describe('Accessibility', () => { it('should have accessible link with title attribute', () => { - // Arrange const props = createHeaderProps({ docTitle: 'Accessible Title' }) - // Act render(<Header {...props} />) - // Assert const titleSpan = screen.getByTitle('Accessible Title') expect(titleSpan).toBeInTheDocument() }) }) }) -// ========================================== // Utils Tests -// ========================================== describe('utils', () => { - // ========================================== // isFile Tests - // ========================================== describe('isFile', () => { it('should return true for file type', () => { - // Act & Assert expect(isFile('file')).toBe(true) }) it('should return false for folder type', () => { - // Act & Assert expect(isFile('folder')).toBe(false) }) @@ -1463,98 +1230,76 @@ describe('utils', () => { ['file', true], ['folder', false], ] as const)('isFile(%s) should return %s', (type, expected) => { - // Act & Assert expect(isFile(type)).toBe(expected) }) }) - // ========================================== // isBucketListInitiation Tests - // ========================================== describe('isBucketListInitiation', () => { it('should return false when bucket is not empty', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, [], 'existing-bucket')).toBe(false) }) it('should return false when prefix is not empty', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, ['folder1'], '')).toBe(false) }) it('should return false when data items have no bucket', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: '', files: [{ id: '1', name: 'file.txt', size: 1024, type: 'file' }], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, [], '')).toBe(false) }) it('should return true for multiple buckets with no prefix and bucket', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, [], '')).toBe(true) }) it('should return true for single bucket with no files, no prefix, and no bucket', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, [], '')).toBe(true) }) it('should return false for single bucket with files', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [{ id: '1', name: 'file.txt', size: 1024, type: 'file' }], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, [], '')).toBe(false) }) it('should return false for empty data array', () => { - // Arrange const data: OnlineDriveData[] = [] - // Act & Assert expect(isBucketListInitiation(data, [], '')).toBe(false) }) }) - // ========================================== // convertOnlineDriveData Tests - // ========================================== describe('convertOnlineDriveData', () => { describe('Empty data handling', () => { it('should return empty result for empty data array', () => { - // Arrange const data: OnlineDriveData[] = [] - // Act const result = convertOnlineDriveData(data, [], '') - // Assert expect(result).toEqual({ fileList: [], isTruncated: false, @@ -1566,17 +1311,14 @@ describe('utils', () => { describe('Bucket list initiation', () => { it('should convert multiple buckets to bucket file list', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, { bucket: 'bucket-3', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act const result = convertOnlineDriveData(data, [], '') - // Assert expect(result.fileList).toHaveLength(3) expect(result.fileList[0]).toEqual({ id: 'bucket-1', @@ -1599,15 +1341,12 @@ describe('utils', () => { }) it('should convert single bucket with no files to bucket list', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act const result = convertOnlineDriveData(data, [], '') - // Assert expect(result.fileList).toHaveLength(1) expect(result.fileList[0]).toEqual({ id: 'my-bucket', @@ -1620,7 +1359,6 @@ describe('utils', () => { describe('File list conversion', () => { it('should convert files correctly', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1633,10 +1371,8 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, ['folder1'], 'my-bucket') - // Assert expect(result.fileList).toHaveLength(2) expect(result.fileList[0]).toEqual({ id: 'file-1', @@ -1654,7 +1390,6 @@ describe('utils', () => { }) it('should convert folders correctly without size', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1667,10 +1402,8 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.fileList).toHaveLength(2) expect(result.fileList[0]).toEqual({ id: 'folder-1', @@ -1687,7 +1420,6 @@ describe('utils', () => { }) it('should handle mixed files and folders', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1702,10 +1434,8 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.fileList).toHaveLength(4) expect(result.fileList[0].type).toBe(OnlineDriveFileType.folder) expect(result.fileList[1].type).toBe(OnlineDriveFileType.file) @@ -1716,7 +1446,6 @@ describe('utils', () => { describe('Truncation and pagination', () => { it('should return isTruncated true when data is truncated', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1726,16 +1455,13 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.isTruncated).toBe(true) expect(result.nextPageParameters).toEqual({ cursor: 'next-cursor' }) }) it('should return isTruncated false when not truncated', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1745,29 +1471,24 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.isTruncated).toBe(false) expect(result.nextPageParameters).toEqual({}) }) it('should handle undefined is_truncated', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [{ id: 'file-1', name: 'file.txt', size: 1024, type: 'file' }], - is_truncated: undefined as any, - next_page_parameters: undefined as any, + is_truncated: undefined as unknown as boolean, + next_page_parameters: undefined as unknown as Record<string, unknown>, }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.isTruncated).toBe(false) expect(result.nextPageParameters).toEqual({}) }) @@ -1775,7 +1496,6 @@ describe('utils', () => { describe('hasBucket flag', () => { it('should return hasBucket true when bucket exists in data', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1785,15 +1505,12 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.hasBucket).toBe(true) }) it('should return hasBucket false when bucket is empty in data', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: '', @@ -1803,17 +1520,14 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], '') - // Assert expect(result.hasBucket).toBe(false) }) }) describe('Edge cases', () => { it('should handle files with zero size', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1823,15 +1537,12 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.fileList[0].size).toBe(0) }) it('should handle files with very large size', () => { - // Arrange const largeSize = Number.MAX_SAFE_INTEGER const data: OnlineDriveData[] = [ { @@ -1842,15 +1553,12 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.fileList[0].size).toBe(largeSize) }) it('should handle files with special characters in name', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1864,17 +1572,14 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.fileList[0].name).toBe('file[1] (copy).txt') expect(result.fileList[1].name).toBe('doc-with-dash_and_underscore.pdf') expect(result.fileList[2].name).toBe('file with spaces.txt') }) it('should handle complex next_page_parameters', () => { - // Arrange const complexParams = { cursor: 'abc123', page: 2, @@ -1890,10 +1595,8 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.nextPageParameters).toEqual(complexParams) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/utils.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/utils.spec.ts new file mode 100644 index 0000000000..7c5761be8a --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/utils.spec.ts @@ -0,0 +1,105 @@ +import type { OnlineDriveData } from '@/types/pipeline' +import { describe, expect, it } from 'vitest' +import { OnlineDriveFileType } from '@/models/pipeline' +import { convertOnlineDriveData, isBucketListInitiation, isFile } from '../utils' + +describe('online-drive utils', () => { + describe('isFile', () => { + it('should return true for file type', () => { + expect(isFile('file')).toBe(true) + }) + + it('should return false for folder type', () => { + expect(isFile('folder')).toBe(false) + }) + }) + + describe('isBucketListInitiation', () => { + it('should return true when data has buckets and no prefix/bucket set', () => { + const data = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, + ] as OnlineDriveData[] + + expect(isBucketListInitiation(data, [], '')).toBe(true) + }) + + it('should return false when bucket is already set', () => { + const data = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + ] as OnlineDriveData[] + + expect(isBucketListInitiation(data, [], 'bucket-1')).toBe(false) + }) + + it('should return false when prefix is set', () => { + const data = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + ] as OnlineDriveData[] + + expect(isBucketListInitiation(data, ['folder/'], '')).toBe(false) + }) + + it('should return false when single bucket has files', () => { + const data = [ + { + bucket: 'bucket-1', + files: [{ id: 'f1', name: 'test.txt', size: 100, type: 'file' as const }], + is_truncated: false, + next_page_parameters: {}, + }, + ] as OnlineDriveData[] + + expect(isBucketListInitiation(data, [], '')).toBe(false) + }) + }) + + describe('convertOnlineDriveData', () => { + it('should return empty result for empty data', () => { + const result = convertOnlineDriveData([], [], '') + expect(result.fileList).toEqual([]) + expect(result.isTruncated).toBe(false) + expect(result.hasBucket).toBe(false) + }) + + it('should convert bucket list initiation to bucket items', () => { + const data = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, + ] as OnlineDriveData[] + + const result = convertOnlineDriveData(data, [], '') + expect(result.fileList).toHaveLength(2) + expect(result.fileList[0]).toEqual({ + id: 'bucket-1', + name: 'bucket-1', + type: OnlineDriveFileType.bucket, + }) + expect(result.hasBucket).toBe(true) + }) + + it('should convert files when not bucket list', () => { + const data = [ + { + bucket: 'bucket-1', + files: [ + { id: 'f1', name: 'test.txt', size: 100, type: 'file' as const }, + { id: 'f2', name: 'folder', size: 0, type: 'folder' as const }, + ], + is_truncated: true, + next_page_parameters: { token: 'next' }, + }, + ] as OnlineDriveData[] + + const result = convertOnlineDriveData(data, [], 'bucket-1') + expect(result.fileList).toHaveLength(2) + expect(result.fileList[0].type).toBe(OnlineDriveFileType.file) + expect(result.fileList[0].size).toBe(100) + expect(result.fileList[1].type).toBe(OnlineDriveFileType.folder) + expect(result.fileList[1].size).toBeUndefined() + expect(result.isTruncated).toBe(true) + expect(result.nextPageParameters).toEqual({ token: 'next' }) + expect(result.hasBucket).toBe(true) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/__tests__/index.spec.tsx similarity index 84% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/__tests__/index.spec.tsx index 174c626243..ce644a8a54 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/__tests__/index.spec.tsx @@ -1,22 +1,13 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import { fireEvent, render, screen } from '@testing-library/react' -import Connect from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import Connect from '../index' // Mock useToolIcon - hook has complex dependencies (API calls, stores) const mockUseToolIcon = vi.fn() vi.mock('@/app/components/workflow/hooks', () => ({ - useToolIcon: (data: any) => mockUseToolIcon(data), + useToolIcon: (data: DataSourceNodeType) => mockUseToolIcon(data), })) -// ========================================== -// Test Data Builders -// ========================================== const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ title: 'Test Node', plugin_id: 'plugin-123', @@ -37,9 +28,6 @@ const createDefaultProps = (overrides?: Partial<ConnectProps>): ConnectProps => ...overrides, }) -// ========================================== -// Test Suites -// ========================================== describe('Connect', () => { beforeEach(() => { vi.clearAllMocks() @@ -48,15 +36,10 @@ describe('Connect', () => { mockUseToolIcon.mockReturnValue('https://example.com/icon.png') }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Component should render with connect button @@ -64,10 +47,8 @@ describe('Connect', () => { }) it('should render the BlockIcon component', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Connect {...props} />) // Assert - BlockIcon container should exist @@ -76,12 +57,10 @@ describe('Connect', () => { }) it('should render the not connected message with node title', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: 'My Google Drive' }), }) - // Act render(<Connect {...props} />) // Assert - Should show translation key with interpolated name (use getAllBy since both messages contain similar text) @@ -90,10 +69,8 @@ describe('Connect', () => { }) it('should render the not connected tip message', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Should show tip translation key @@ -101,10 +78,8 @@ describe('Connect', () => { }) it('should render the connect button with correct text', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Button should have connect text @@ -113,10 +88,8 @@ describe('Connect', () => { }) it('should render with primary button variant', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Button should be primary variant @@ -125,10 +98,8 @@ describe('Connect', () => { }) it('should render Icon3Dots component', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Connect {...props} />) // Assert - Icon3Dots should be rendered (it's an SVG element) @@ -137,10 +108,8 @@ describe('Connect', () => { }) it('should apply correct container styling', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Connect {...props} />) // Assert - Container should have expected classes @@ -149,30 +118,22 @@ describe('Connect', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('nodeData prop', () => { it('should pass nodeData to useToolIcon hook', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'my-plugin' }) const props = createDefaultProps({ nodeData }) - // Act render(<Connect {...props} />) - // Assert expect(mockUseToolIcon).toHaveBeenCalledWith(nodeData) }) it('should display node title in not connected message', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: 'Dropbox Storage' }), }) - // Act render(<Connect {...props} />) // Assert - Translation key should be in document (mock returns key) @@ -181,12 +142,10 @@ describe('Connect', () => { }) it('should display node title in tip message', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: 'OneDrive Connector' }), }) - // Act render(<Connect {...props} />) // Assert - Translation key should be in document @@ -200,12 +159,10 @@ describe('Connect', () => { { title: 'Amazon S3' }, { title: '' }, ])('should handle nodeData with title=$title', ({ title }) => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title }), }) - // Act render(<Connect {...props} />) // Assert - Should render without error @@ -215,24 +172,19 @@ describe('Connect', () => { describe('onSetting prop', () => { it('should call onSetting when connect button is clicked', () => { - // Arrange const mockOnSetting = vi.fn() const props = createDefaultProps({ onSetting: mockOnSetting }) - // Act render(<Connect {...props} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSetting).toHaveBeenCalledTimes(1) }) it('should call onSetting when button clicked', () => { - // Arrange const mockOnSetting = vi.fn() const props = createDefaultProps({ onSetting: mockOnSetting }) - // Act render(<Connect {...props} />) fireEvent.click(screen.getByRole('button')) @@ -242,60 +194,47 @@ describe('Connect', () => { }) it('should call onSetting on each button click', () => { - // Arrange const mockOnSetting = vi.fn() const props = createDefaultProps({ onSetting: mockOnSetting }) - // Act render(<Connect {...props} />) const button = screen.getByRole('button') fireEvent.click(button) fireEvent.click(button) fireEvent.click(button) - // Assert expect(mockOnSetting).toHaveBeenCalledTimes(3) }) }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions', () => { describe('Connect Button', () => { it('should trigger onSetting callback on click', () => { - // Arrange const mockOnSetting = vi.fn() const props = createDefaultProps({ onSetting: mockOnSetting }) render(<Connect {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSetting).toHaveBeenCalled() }) it('should be interactive and focusable', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) const button = screen.getByRole('button') - // Assert expect(button).not.toBeDisabled() }) it('should handle keyboard interaction (Enter key)', () => { - // Arrange const mockOnSetting = vi.fn() const props = createDefaultProps({ onSetting: mockOnSetting }) render(<Connect {...props} />) - // Act const button = screen.getByRole('button') fireEvent.keyDown(button, { key: 'Enter' }) @@ -305,29 +244,22 @@ describe('Connect', () => { }) }) - // ========================================== // Hook Integration Tests - // ========================================== describe('Hook Integration', () => { describe('useToolIcon', () => { it('should call useToolIcon with nodeData', () => { - // Arrange const nodeData = createMockNodeData() const props = createDefaultProps({ nodeData }) - // Act render(<Connect {...props} />) - // Assert expect(mockUseToolIcon).toHaveBeenCalledWith(nodeData) }) it('should use toolIcon result from useToolIcon', () => { - // Arrange mockUseToolIcon.mockReturnValue('custom-icon-url') const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - The hook should be called and its return value used @@ -335,11 +267,9 @@ describe('Connect', () => { }) it('should handle empty string icon', () => { - // Arrange mockUseToolIcon.mockReturnValue('') const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Should still render without crashing @@ -347,11 +277,9 @@ describe('Connect', () => { }) it('should handle undefined icon', () => { - // Arrange mockUseToolIcon.mockReturnValue(undefined) const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Should still render without crashing @@ -361,10 +289,8 @@ describe('Connect', () => { describe('useTranslation', () => { it('should use correct translation keys for not connected message', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Should use the correct translation key (both notConnected and notConnectedTip contain similar pattern) @@ -373,49 +299,36 @@ describe('Connect', () => { }) it('should use correct translation key for tip message', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) - // Assert expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument() }) it('should use correct translation key for connect button', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toHaveTextContent('datasetCreation.stepOne.connect') }) }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { describe('Empty/Null Values', () => { it('should handle empty title in nodeData', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: '' }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should handle undefined optional fields in nodeData', () => { - // Arrange const minimalNodeData = { title: 'Test', plugin_id: 'test', @@ -428,35 +341,28 @@ describe('Connect', () => { } as DataSourceNodeType const props = createDefaultProps({ nodeData: minimalNodeData }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should handle empty plugin_id', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ plugin_id: '' }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) describe('Special Characters', () => { it('should handle special characters in title', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: 'Drive <script>alert("xss")</script>' }), }) - // Act render(<Connect {...props} />) // Assert - Should render safely without executing script @@ -464,75 +370,57 @@ describe('Connect', () => { }) it('should handle unicode characters in title', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: 'äș‘ç›˜ć­˜ć‚š 🌐' }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should handle very long title', () => { - // Arrange const longTitle = 'A'.repeat(500) const props = createDefaultProps({ nodeData: createMockNodeData({ title: longTitle }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) describe('Icon Variations', () => { it('should handle string icon URL', () => { - // Arrange mockUseToolIcon.mockReturnValue('https://cdn.example.com/icon.png') const props = createDefaultProps() - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should handle object icon with url property', () => { - // Arrange mockUseToolIcon.mockReturnValue({ url: 'https://cdn.example.com/icon.png' }) const props = createDefaultProps() - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should handle null icon', () => { - // Arrange mockUseToolIcon.mockReturnValue(null) const props = createDefaultProps() - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { title: 'Google Drive', plugin_id: 'google-drive' }, @@ -541,15 +429,12 @@ describe('Connect', () => { { title: 'Amazon S3', plugin_id: 's3' }, { title: 'Box', plugin_id: 'box' }, ])('should render correctly with title=$title and plugin_id=$plugin_id', ({ title, plugin_id }) => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title, plugin_id }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() expect(mockUseToolIcon).toHaveBeenCalledWith( expect.objectContaining({ title, plugin_id }), @@ -561,15 +446,12 @@ describe('Connect', () => { { provider_type: 'cloud_storage' }, { provider_type: 'file_system' }, ])('should render correctly with provider_type=$provider_type', ({ provider_type }) => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ provider_type }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) @@ -579,28 +461,20 @@ describe('Connect', () => { { datasource_label: '' }, { datasource_label: 'S3 Bucket' }, ])('should render correctly with datasource_label=$datasource_label', ({ datasource_label }) => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ datasource_label }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) - // ========================================== - // Accessibility Tests - // ========================================== describe('Accessibility', () => { it('should have an accessible button', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Button should be accessible by role @@ -608,10 +482,8 @@ describe('Connect', () => { }) it('should have proper text content for screen readers', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Text content should be present diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx index 2ad62aae8e..c441709ec2 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx @@ -2,18 +2,13 @@ import type { OnlineDriveFile } from '@/models/pipeline' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { OnlineDriveFileType } from '@/models/pipeline' -import FileList from './index' +import FileList from '../index' -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts - -// Mock ahooks useDebounceFn - third-party library requires mocking +// Mock ahooks useDebounceFn: required because tests verify the debounced +// callback is invoked with specific arguments (mockDebounceFnRun assertions). const mockDebounceFnRun = vi.fn() vi.mock('ahooks', () => ({ - useDebounceFn: (fn: (...args: any[]) => void) => { + useDebounceFn: (fn: (...args: unknown[]) => void) => { mockDebounceFnRun.mockImplementation(fn) return { run: mockDebounceFnRun } }, @@ -35,14 +30,11 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, - useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState), + useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), })) -// ========================================== -// Test Data Builders -// ========================================== const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({ id: 'file-1', name: 'test-file.txt', @@ -70,9 +62,6 @@ const createDefaultProps = (overrides?: Partial<FileListProps>): FileListProps = ...overrides, }) -// ========================================== -// Helper Functions -// ========================================== const resetMockStoreState = () => { mockStoreState.setNextPageParameters = vi.fn() mockStoreState.currentNextPageParametersRef = { current: {} } @@ -85,9 +74,6 @@ const resetMockStoreState = () => { mockStoreState.setBucket = vi.fn() } -// ========================================== -// Test Suites -// ========================================== describe('FileList', () => { beforeEach(() => { vi.clearAllMocks() @@ -95,15 +81,10 @@ describe('FileList', () => { mockDebounceFnRun.mockClear() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<FileList {...props} />) // Assert - search input should be visible @@ -111,13 +92,10 @@ describe('FileList', () => { }) it('should render with correct container styles', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<FileList {...props} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('h-[400px]') @@ -127,38 +105,30 @@ describe('FileList', () => { }) it('should render Header component with search input', () => { - // Arrange const props = createDefaultProps() - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toBeInTheDocument() }) it('should render files when fileList has items', () => { - // Arrange const fileList = [ createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }), createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }), ] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('file1.txt')).toBeInTheDocument() expect(screen.getByText('file2.txt')).toBeInTheDocument() }) it('should show loading state when isLoading is true and fileList is empty', () => { - // Arrange const props = createDefaultProps({ isLoading: true, fileList: [] }) - // Act const { container } = render(<FileList {...props} />) // Assert - Loading component should be rendered with spin-animation class @@ -166,35 +136,25 @@ describe('FileList', () => { }) it('should show empty folder state when not loading and fileList is empty', () => { - // Arrange const props = createDefaultProps({ isLoading: false, fileList: [], keywords: '' }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument() }) it('should show empty search result when not loading, fileList is empty, and keywords exist', () => { - // Arrange const props = createDefaultProps({ isLoading: false, fileList: [], keywords: 'search-term' }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('datasetPipeline.onlineDrive.emptySearchResult')).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('fileList prop', () => { it('should render all files from fileList', () => { - // Arrange const fileList = [ createMockOnlineDriveFile({ id: '1', name: 'a.txt' }), createMockOnlineDriveFile({ id: '2', name: 'b.txt' }), @@ -202,20 +162,16 @@ describe('FileList', () => { ] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('a.txt')).toBeInTheDocument() expect(screen.getByText('b.txt')).toBeInTheDocument() expect(screen.getByText('c.txt')).toBeInTheDocument() }) it('should handle empty fileList', () => { - // Arrange const props = createDefaultProps({ fileList: [] }) - // Act render(<FileList {...props} />) // Assert - Should show empty folder state @@ -225,14 +181,12 @@ describe('FileList', () => { describe('selectedFileIds prop', () => { it('should mark files as selected based on selectedFileIds', () => { - // Arrange const fileList = [ createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }), createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }), ] const props = createDefaultProps({ fileList, selectedFileIds: ['file-1'] }) - // Act render(<FileList {...props} />) // Assert - The checkbox for file-1 should be checked (check icon present) @@ -245,13 +199,10 @@ describe('FileList', () => { describe('keywords prop', () => { it('should initialize input with keywords value', () => { - // Arrange const props = createDefaultProps({ keywords: 'my-search' }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('my-search') }) @@ -259,10 +210,8 @@ describe('FileList', () => { describe('isLoading prop', () => { it('should show loading when isLoading is true with empty list', () => { - // Arrange const props = createDefaultProps({ isLoading: true, fileList: [] }) - // Act const { container } = render(<FileList {...props} />) // Assert - Loading component with spin-animation class @@ -270,11 +219,9 @@ describe('FileList', () => { }) it('should show loading indicator at bottom when isLoading is true with files', () => { - // Arrange const fileList = [createMockOnlineDriveFile()] const props = createDefaultProps({ isLoading: true, fileList }) - // Act const { container } = render(<FileList {...props} />) // Assert - Should show spinner icon at the bottom @@ -284,11 +231,9 @@ describe('FileList', () => { describe('supportBatchUpload prop', () => { it('should render checkboxes when supportBatchUpload is true', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' })] const props = createDefaultProps({ fileList, supportBatchUpload: true }) - // Act render(<FileList {...props} />) // Assert - Checkbox component has data-testid="checkbox-{id}" @@ -296,11 +241,9 @@ describe('FileList', () => { }) it('should render radio buttons when supportBatchUpload is false', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' })] const props = createDefaultProps({ fileList, supportBatchUpload: false }) - // Act const { container } = render(<FileList {...props} />) // Assert - Radio is rendered as a div with rounded-full class @@ -311,99 +254,76 @@ describe('FileList', () => { }) }) - // ========================================== - // State Management Tests - // ========================================== describe('State Management', () => { describe('inputValue state', () => { it('should initialize inputValue with keywords prop', () => { - // Arrange const props = createDefaultProps({ keywords: 'initial-keyword' }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('initial-keyword') }) it('should update inputValue when input changes', () => { - // Arrange const props = createDefaultProps({ keywords: '' }) render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'new-value' } }) - // Assert expect(input).toHaveValue('new-value') }) }) describe('debounced keywords update', () => { it('should call updateKeywords with debounce when input changes', () => { - // Arrange const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'debounced-value' } }) - // Assert expect(mockDebounceFnRun).toHaveBeenCalledWith('debounced-value') }) }) }) - // ========================================== // Event Handlers Tests - // ========================================== describe('Event Handlers', () => { describe('handleInputChange', () => { it('should update inputValue on input change', () => { - // Arrange const props = createDefaultProps() render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'typed-text' } }) - // Assert expect(input).toHaveValue('typed-text') }) it('should trigger debounced updateKeywords on input change', () => { - // Arrange const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'search-term' } }) - // Assert expect(mockDebounceFnRun).toHaveBeenCalledWith('search-term') }) it('should handle multiple sequential input changes', () => { - // Arrange const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'a' } }) fireEvent.change(input, { target: { value: 'ab' } }) fireEvent.change(input, { target: { value: 'abc' } }) - // Assert expect(mockDebounceFnRun).toHaveBeenCalledTimes(3) expect(mockDebounceFnRun).toHaveBeenLastCalledWith('abc') expect(input).toHaveValue('abc') @@ -412,7 +332,6 @@ describe('FileList', () => { describe('handleResetKeywords', () => { it('should call resetKeywords prop when clear button is clicked', () => { - // Arrange const mockResetKeywords = vi.fn() const props = createDefaultProps({ resetKeywords: mockResetKeywords, keywords: 'to-reset' }) const { container } = render(<FileList {...props} />) @@ -422,12 +341,10 @@ describe('FileList', () => { expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) - // Assert expect(mockResetKeywords).toHaveBeenCalledTimes(1) }) it('should reset inputValue to empty string when clear is clicked', () => { - // Arrange const props = createDefaultProps({ keywords: 'to-be-reset' }) const { container } = render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') @@ -438,14 +355,12 @@ describe('FileList', () => { expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) - // Assert expect(input).toHaveValue('') }) }) describe('handleSelectFile', () => { it('should call handleSelectFile when file item is clicked', () => { - // Arrange const mockHandleSelectFile = vi.fn() const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })] const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList }) @@ -455,7 +370,6 @@ describe('FileList', () => { const fileItem = screen.getByText('test.txt') fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!) - // Assert expect(mockHandleSelectFile).toHaveBeenCalledWith(expect.objectContaining({ id: 'file-1', name: 'test.txt', @@ -466,7 +380,6 @@ describe('FileList', () => { describe('handleOpenFolder', () => { it('should call handleOpenFolder when folder item is clicked', () => { - // Arrange const mockHandleOpenFolder = vi.fn() const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList }) @@ -476,7 +389,6 @@ describe('FileList', () => { const folderItem = screen.getByText('my-folder') fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!) - // Assert expect(mockHandleOpenFolder).toHaveBeenCalledWith(expect.objectContaining({ id: 'folder-1', name: 'my-folder', @@ -486,68 +398,51 @@ describe('FileList', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty string keywords', () => { - // Arrange const props = createDefaultProps({ keywords: '' }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('') }) it('should handle special characters in keywords', () => { - // Arrange const specialChars = 'test[file].txt (copy)' const props = createDefaultProps({ keywords: specialChars }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(specialChars) }) it('should handle unicode characters in keywords', () => { - // Arrange const unicodeKeywords = 'æ–‡ä»¶æœçŽą æ—„æœŹèȘž' const props = createDefaultProps({ keywords: unicodeKeywords }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(unicodeKeywords) }) it('should handle very long file names in fileList', () => { - // Arrange const longName = `${'a'.repeat(100)}.txt` const fileList = [createMockOnlineDriveFile({ id: '1', name: longName })] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle large number of files', () => { - // Arrange const fileList = Array.from({ length: 50 }, (_, i) => createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` })) const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) // Assert - Check a few files exist @@ -556,23 +451,17 @@ describe('FileList', () => { }) it('should handle whitespace-only keywords input', () => { - // Arrange const props = createDefaultProps() render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: ' ' } }) - // Assert expect(input).toHaveValue(' ') expect(mockDebounceFnRun).toHaveBeenCalledWith(' ') }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { isInPipeline: true, supportBatchUpload: true }, @@ -580,10 +469,8 @@ describe('FileList', () => { { isInPipeline: false, supportBatchUpload: true }, { isInPipeline: false, supportBatchUpload: false }, ])('should render correctly with isInPipeline=$isInPipeline and supportBatchUpload=$supportBatchUpload', (propVariation) => { - // Arrange const props = createDefaultProps(propVariation) - // Act render(<FileList {...props} />) // Assert - Component should render without crashing @@ -595,15 +482,12 @@ describe('FileList', () => { { isLoading: false, fileCount: 0, description: 'not loading with no files' }, { isLoading: false, fileCount: 3, description: 'not loading with files' }, ])('should handle $description correctly', ({ isLoading, fileCount }) => { - // Arrange const fileList = Array.from({ length: fileCount }, (_, i) => createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` })) const props = createDefaultProps({ isLoading, fileList }) - // Act const { container } = render(<FileList {...props} />) - // Assert if (isLoading && fileCount === 0) expect(container.querySelector('.spin-animation')).toBeInTheDocument() @@ -619,66 +503,50 @@ describe('FileList', () => { { keywords: 'test', searchResultsLength: 5 }, { keywords: 'not-found', searchResultsLength: 0 }, ])('should render correctly with keywords="$keywords" and searchResultsLength=$searchResultsLength', ({ keywords, searchResultsLength }) => { - // Arrange const props = createDefaultProps({ keywords, searchResultsLength }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(keywords) }) }) - // ========================================== // File Type Variations - // ========================================== describe('File Type Variations', () => { it('should render folder type correctly', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('my-folder')).toBeInTheDocument() }) it('should render bucket type correctly', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('my-bucket')).toBeInTheDocument() }) it('should render file with size', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt', size: 1024 })] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('test.txt')).toBeInTheDocument() // formatFileSize returns '1.00 KB' for 1024 bytes expect(screen.getByText('1.00 KB')).toBeInTheDocument() }) it('should not show checkbox for bucket type', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })] const props = createDefaultProps({ fileList, supportBatchUpload: true }) - // Act render(<FileList {...props} />) // Assert - No checkbox should be rendered for bucket @@ -686,32 +554,24 @@ describe('FileList', () => { }) }) - // ========================================== // Search Results Display - // ========================================== describe('Search Results Display', () => { it('should show search results count when keywords and results exist', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 5, breadcrumbs: ['folder1'], }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText(/datasetPipeline\.onlineDrive\.breadcrumbs\.searchResult/)).toBeInTheDocument() }) }) - // ========================================== // Callback Stability - // ========================================== describe('Callback Stability', () => { it('should maintain stable handleSelectFile callback', () => { - // Arrange const mockHandleSelectFile = vi.fn() const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })] const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList }) @@ -724,15 +584,12 @@ describe('FileList', () => { // Rerender with same props rerender(<FileList {...props} />) - // Click again fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!) - // Assert expect(mockHandleSelectFile).toHaveBeenCalledTimes(2) }) it('should maintain stable handleOpenFolder callback', () => { - // Arrange const mockHandleOpenFolder = vi.fn() const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList }) @@ -745,10 +602,8 @@ describe('FileList', () => { // Rerender with same props rerender(<FileList {...props} />) - // Click again fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!) - // Assert expect(mockHandleOpenFolder).toHaveBeenCalledTimes(2) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx index 3c836465b8..ef94fd3dc8 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx @@ -1,12 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import Header from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import Header from '../index' // Mock store - required by Breadcrumbs component const mockStoreState = { @@ -23,14 +17,11 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../../../store', () => ({ +vi.mock('../../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), })) -// ========================================== -// Test Data Builders -// ========================================== type HeaderProps = React.ComponentProps<typeof Header> const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({ @@ -45,9 +36,6 @@ const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({ ...overrides, }) -// ========================================== -// Helper Functions -// ========================================== const resetMockStoreState = () => { mockStoreState.hasBucket = false mockStoreState.setOnlineDriveFileList = vi.fn() @@ -59,24 +47,16 @@ const resetMockStoreState = () => { mockStoreState.prefix = [] } -// ========================================== -// Test Suites -// ========================================== describe('Header', () => { beforeEach(() => { vi.clearAllMocks() resetMockStoreState() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<Header {...props} />) // Assert - search input should be visible @@ -84,10 +64,8 @@ describe('Header', () => { }) it('should render with correct container styles', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Header {...props} />) // Assert - container should have correct class names @@ -101,23 +79,18 @@ describe('Header', () => { }) it('should render Input component with correct props', () => { - // Arrange const props = createDefaultProps({ inputValue: 'test-value' }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toBeInTheDocument() expect(input).toHaveValue('test-value') }) it('should render Input with search icon', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Header {...props} />) // Assert - Input should have search icon (RiSearchLine is rendered as svg) @@ -126,10 +99,8 @@ describe('Header', () => { }) it('should render Input with correct wrapper width', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Header {...props} />) // Assert - Input wrapper should have w-[200px] class @@ -138,57 +109,42 @@ describe('Header', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('inputValue prop', () => { it('should display empty input when inputValue is empty string', () => { - // Arrange const props = createDefaultProps({ inputValue: '' }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('') }) it('should display input value correctly', () => { - // Arrange const props = createDefaultProps({ inputValue: 'search-query' }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('search-query') }) it('should handle special characters in inputValue', () => { - // Arrange const specialChars = 'test[file].txt (copy)' const props = createDefaultProps({ inputValue: specialChars }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(specialChars) }) it('should handle unicode characters in inputValue', () => { - // Arrange const unicodeValue = 'æ–‡ä»¶æœçŽą æ—„æœŹèȘž' const props = createDefaultProps({ inputValue: unicodeValue }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(unicodeValue) }) @@ -196,10 +152,8 @@ describe('Header', () => { describe('breadcrumbs prop', () => { it('should render with empty breadcrumbs', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: [] }) - // Act render(<Header {...props} />) // Assert - Component should render without errors @@ -207,34 +161,26 @@ describe('Header', () => { }) it('should render with single breadcrumb', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder1'] }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should render with multiple breadcrumbs', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'] }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) }) describe('keywords prop', () => { it('should pass keywords to Breadcrumbs', () => { - // Arrange const props = createDefaultProps({ keywords: 'search-keyword' }) - // Act render(<Header {...props} />) // Assert - keywords are passed through, component renders @@ -244,45 +190,34 @@ describe('Header', () => { describe('bucket prop', () => { it('should render with empty bucket', () => { - // Arrange const props = createDefaultProps({ bucket: '' }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should render with bucket value', () => { - // Arrange const props = createDefaultProps({ bucket: 'my-bucket' }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) }) describe('searchResultsLength prop', () => { it('should handle zero search results', () => { - // Arrange const props = createDefaultProps({ searchResultsLength: 0 }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should handle positive search results', () => { - // Arrange const props = createDefaultProps({ searchResultsLength: 10, keywords: 'test' }) - // Act render(<Header {...props} />) // Assert - Breadcrumbs will show search results text when keywords exist and results > 0 @@ -290,105 +225,82 @@ describe('Header', () => { }) it('should handle large search results count', () => { - // Arrange const props = createDefaultProps({ searchResultsLength: 1000, keywords: 'test' }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) }) describe('isInPipeline prop', () => { it('should render correctly when isInPipeline is false', () => { - // Arrange const props = createDefaultProps({ isInPipeline: false }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should render correctly when isInPipeline is true', () => { - // Arrange const props = createDefaultProps({ isInPipeline: true }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) }) }) - // ========================================== // Event Handlers Tests - // ========================================== describe('Event Handlers', () => { describe('handleInputChange', () => { it('should call handleInputChange when input value changes', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'new-value' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledTimes(1) // Verify that onChange event was triggered (React's synthetic event structure) expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change') }) it('should call handleInputChange on each keystroke', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'a' } }) fireEvent.change(input, { target: { value: 'ab' } }) fireEvent.change(input, { target: { value: 'abc' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledTimes(3) }) it('should handle empty string input', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ inputValue: 'existing', handleInputChange: mockHandleInputChange }) render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: '' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledTimes(1) expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change') }) it('should handle whitespace-only input', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: ' ' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledTimes(1) expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change') }) @@ -396,7 +308,6 @@ describe('Header', () => { describe('handleResetKeywords', () => { it('should call handleResetKeywords when clear icon is clicked', () => { - // Arrange const mockHandleResetKeywords = vi.fn() const props = createDefaultProps({ inputValue: 'to-clear', @@ -409,12 +320,10 @@ describe('Header', () => { expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) - // Assert expect(mockHandleResetKeywords).toHaveBeenCalledTimes(1) }) it('should not show clear icon when inputValue is empty', () => { - // Arrange const props = createDefaultProps({ inputValue: '' }) const { container } = render(<Header {...props} />) @@ -424,7 +333,6 @@ describe('Header', () => { }) it('should show clear icon when inputValue is not empty', () => { - // Arrange const props = createDefaultProps({ inputValue: 'some-value' }) const { container } = render(<Header {...props} />) @@ -435,9 +343,7 @@ describe('Header', () => { }) }) - // ========================================== // Component Memoization Tests - // ========================================== describe('Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - Header component should be memoized @@ -445,7 +351,6 @@ describe('Header', () => { }) it('should not re-render when props are the same', () => { - // Arrange const mockHandleInputChange = vi.fn() const mockHandleResetKeywords = vi.fn() const props = createDefaultProps({ @@ -464,7 +369,6 @@ describe('Header', () => { }) it('should re-render when inputValue changes', () => { - // Arrange const props = createDefaultProps({ inputValue: 'initial' }) const { rerender } = render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') @@ -479,7 +383,6 @@ describe('Header', () => { }) it('should re-render when breadcrumbs change', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: [] }) const { rerender } = render(<Header {...props} />) @@ -492,7 +395,6 @@ describe('Header', () => { }) it('should re-render when keywords change', () => { - // Arrange const props = createDefaultProps({ keywords: '' }) const { rerender } = render(<Header {...props} />) @@ -505,78 +407,58 @@ describe('Header', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle very long inputValue', () => { - // Arrange const longValue = 'a'.repeat(500) const props = createDefaultProps({ inputValue: longValue }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(longValue) }) it('should handle very long breadcrumb paths', () => { - // Arrange const longBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`) const props = createDefaultProps({ breadcrumbs: longBreadcrumbs }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should handle breadcrumbs with special characters', () => { - // Arrange const specialBreadcrumbs = ['folder [1]', 'folder (2)', 'folder-3.backup'] const props = createDefaultProps({ breadcrumbs: specialBreadcrumbs }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should handle breadcrumbs with unicode names', () => { - // Arrange const unicodeBreadcrumbs = ['文件ć€č', 'ăƒ•ă‚©ăƒ«ăƒ€', 'ПапĐșа'] const props = createDefaultProps({ breadcrumbs: unicodeBreadcrumbs }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should handle bucket with special characters', () => { - // Arrange const props = createDefaultProps({ bucket: 'my-bucket_2024.backup' }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should pass the event object to handleInputChange callback', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'test-value' } }) // Assert - Verify the event object is passed correctly @@ -587,9 +469,6 @@ describe('Header', () => { }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { isInPipeline: true, bucket: '' }, @@ -597,13 +476,10 @@ describe('Header', () => { { isInPipeline: false, bucket: '' }, { isInPipeline: false, bucket: 'my-bucket' }, ])('should render correctly with isInPipeline=$isInPipeline and bucket=$bucket', (propVariation) => { - // Arrange const props = createDefaultProps(propVariation) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) @@ -613,13 +489,10 @@ describe('Header', () => { { keywords: 'test', searchResultsLength: 5, description: 'search with results' }, { keywords: '', searchResultsLength: 5, description: 'no keywords but has results count' }, ])('should render correctly with $description', ({ keywords, searchResultsLength }) => { - // Arrange const props = createDefaultProps({ keywords, searchResultsLength }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) @@ -629,24 +502,18 @@ describe('Header', () => { { breadcrumbs: ['a', 'b', 'c'], inputValue: '', expected: 'multiple breadcrumbs no search' }, { breadcrumbs: ['a', 'b', 'c', 'd', 'e'], inputValue: 'query', expected: 'many breadcrumbs with search' }, ])('should handle $expected correctly', ({ breadcrumbs, inputValue }) => { - // Arrange const props = createDefaultProps({ breadcrumbs, inputValue }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(inputValue) }) }) - // ========================================== // Integration with Child Components - // ========================================== describe('Integration with Child Components', () => { it('should pass all required props to Breadcrumbs', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], keywords: 'test-keyword', @@ -655,7 +522,6 @@ describe('Header', () => { isInPipeline: true, }) - // Act render(<Header {...props} />) // Assert - Component should render successfully, meaning props are passed correctly @@ -663,7 +529,6 @@ describe('Header', () => { }) it('should pass correct props to Input component', () => { - // Arrange const mockHandleInputChange = vi.fn() const mockHandleResetKeywords = vi.fn() const props = createDefaultProps({ @@ -672,10 +537,8 @@ describe('Header', () => { handleResetKeywords: mockHandleResetKeywords, }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('test-input') @@ -685,12 +548,9 @@ describe('Header', () => { }) }) - // ========================================== // Callback Stability Tests - // ========================================== describe('Callback Stability', () => { it('should maintain stable handleInputChange callback after rerender', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) const { rerender } = render(<Header {...props} />) @@ -701,12 +561,10 @@ describe('Header', () => { rerender(<Header {...props} />) fireEvent.change(input, { target: { value: 'second' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledTimes(2) }) it('should maintain stable handleResetKeywords callback after rerender', () => { - // Arrange const mockHandleResetKeywords = vi.fn() const props = createDefaultProps({ inputValue: 'to-clear', @@ -720,7 +578,6 @@ describe('Header', () => { rerender(<Header {...props} />) fireEvent.click(clearButton!) - // Assert expect(mockHandleResetKeywords).toHaveBeenCalledTimes(2) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx new file mode 100644 index 0000000000..c407be51ac --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Bucket from '../bucket' + +vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({ + BucketsGray: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="buckets-gray" {...props} />, +})) +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>, +})) + +describe('Bucket', () => { + const defaultProps = { + bucketName: 'my-bucket', + handleBackToBucketList: vi.fn(), + handleClickBucketName: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render bucket name', () => { + render(<Bucket {...defaultProps} />) + expect(screen.getByText('my-bucket')).toBeInTheDocument() + }) + + it('should render bucket icon', () => { + render(<Bucket {...defaultProps} />) + expect(screen.getByTestId('buckets-gray')).toBeInTheDocument() + }) + + it('should call handleBackToBucketList on icon button click', () => { + render(<Bucket {...defaultProps} />) + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + expect(defaultProps.handleBackToBucketList).toHaveBeenCalledOnce() + }) + + it('should call handleClickBucketName on name click', () => { + render(<Bucket {...defaultProps} />) + fireEvent.click(screen.getByText('my-bucket')) + expect(defaultProps.handleClickBucketName).toHaveBeenCalledOnce() + }) + + it('should not call handleClickBucketName when disabled', () => { + render(<Bucket {...defaultProps} disabled={true} />) + fireEvent.click(screen.getByText('my-bucket')) + expect(defaultProps.handleClickBucketName).not.toHaveBeenCalled() + }) + + it('should show separator by default', () => { + render(<Bucket {...defaultProps} />) + const separators = screen.getAllByText('/') + expect(separators.length).toBeGreaterThanOrEqual(2) // One after icon, one after name + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx new file mode 100644 index 0000000000..ce3bab6d01 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Drive from '../drive' + +describe('Drive', () => { + const defaultProps = { + breadcrumbs: [] as string[], + handleBackToRoot: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: button text and separator visibility + describe('Rendering', () => { + it('should render "All Files" button text', () => { + render(<Drive {...defaultProps} />) + + expect(screen.getByRole('button')).toHaveTextContent('datasetPipeline.onlineDrive.breadcrumbs.allFiles') + }) + + it('should show separator "/" when breadcrumbs has items', () => { + render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />) + + expect(screen.getByText('/')).toBeInTheDocument() + }) + + it('should hide separator when breadcrumbs is empty', () => { + render(<Drive {...defaultProps} breadcrumbs={[]} />) + + expect(screen.queryByText('/')).not.toBeInTheDocument() + }) + }) + + // Props: disabled state depends on breadcrumbs length + describe('Props', () => { + it('should disable button when breadcrumbs is empty', () => { + render(<Drive {...defaultProps} breadcrumbs={[]} />) + + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should enable button when breadcrumbs has items', () => { + render(<Drive {...defaultProps} breadcrumbs={['Folder A', 'Folder B']} />) + + expect(screen.getByRole('button')).not.toBeDisabled() + }) + }) + + // User interactions: clicking the root button + describe('User Interactions', () => { + it('should call handleBackToRoot on click when enabled', () => { + render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />) + + fireEvent.click(screen.getByRole('button')) + + expect(defaultProps.handleBackToRoot).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx index b7e53ed1be..a6aaf3a50b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx @@ -1,12 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import Breadcrumbs from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import Breadcrumbs from '../index' // Mock store - context provider requires mocking const mockStoreState = { @@ -23,14 +17,11 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../../../../store', () => ({ +vi.mock('../../../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), })) -// ========================================== -// Test Data Builders -// ========================================== type BreadcrumbsProps = React.ComponentProps<typeof Breadcrumbs> const createDefaultProps = (overrides?: Partial<BreadcrumbsProps>): BreadcrumbsProps => ({ @@ -42,9 +33,6 @@ const createDefaultProps = (overrides?: Partial<BreadcrumbsProps>): BreadcrumbsP ...overrides, }) -// ========================================== -// Helper Functions -// ========================================== const resetMockStoreState = () => { mockStoreState.hasBucket = false mockStoreState.breadcrumbs = [] @@ -56,24 +44,16 @@ const resetMockStoreState = () => { mockStoreState.setBucket = vi.fn() } -// ========================================== -// Test Suites -// ========================================== describe('Breadcrumbs', () => { beforeEach(() => { vi.clearAllMocks() resetMockStoreState() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<Breadcrumbs {...props} />) // Assert - Container should be in the document @@ -82,13 +62,10 @@ describe('Breadcrumbs', () => { }) it('should render with correct container styles', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Breadcrumbs {...props} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('grow') @@ -98,14 +75,12 @@ describe('Breadcrumbs', () => { describe('Search Results Display', () => { it('should show search results when keywords and searchResultsLength > 0', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 5, breadcrumbs: ['folder1'], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Search result text should be displayed @@ -113,36 +88,29 @@ describe('Breadcrumbs', () => { }) it('should not show search results when keywords is empty', () => { - // Arrange const props = createDefaultProps({ keywords: '', searchResultsLength: 5, breadcrumbs: ['folder1'], }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.queryByText(/searchResult/)).not.toBeInTheDocument() }) it('should not show search results when searchResultsLength is 0', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 0, }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.queryByText(/searchResult/)).not.toBeInTheDocument() }) it('should use bucket as folderName when breadcrumbs is empty', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 5, @@ -150,7 +118,6 @@ describe('Breadcrumbs', () => { bucket: 'my-bucket', }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should use bucket name in search result @@ -158,7 +125,6 @@ describe('Breadcrumbs', () => { }) it('should use last breadcrumb as folderName when breadcrumbs exist', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 5, @@ -166,7 +132,6 @@ describe('Breadcrumbs', () => { bucket: 'my-bucket', }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should use last breadcrumb in search result @@ -176,7 +141,6 @@ describe('Breadcrumbs', () => { describe('All Buckets Title Display', () => { it('should show all buckets title when hasBucket=true, bucket is empty, and no breadcrumbs', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ breadcrumbs: [], @@ -184,37 +148,30 @@ describe('Breadcrumbs', () => { keywords: '', }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).toBeInTheDocument() }) it('should not show all buckets title when breadcrumbs exist', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ breadcrumbs: ['folder1'], bucket: '', }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).not.toBeInTheDocument() }) it('should not show all buckets title when bucket is set', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ breadcrumbs: [], bucket: 'my-bucket', }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should show bucket name instead @@ -224,14 +181,12 @@ describe('Breadcrumbs', () => { describe('Bucket Component Display', () => { it('should render Bucket component when hasBucket and bucket are set', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'test-bucket', breadcrumbs: [], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Bucket name should be displayed @@ -239,14 +194,12 @@ describe('Breadcrumbs', () => { }) it('should not render Bucket when hasBucket is false', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ bucket: 'test-bucket', breadcrumbs: [], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Bucket should not be displayed, Drive should be shown instead @@ -256,13 +209,11 @@ describe('Breadcrumbs', () => { describe('Drive Component Display', () => { it('should render Drive component when hasBucket is false', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: [], }) - // Act render(<Breadcrumbs {...props} />) // Assert - "All Files" should be displayed @@ -270,46 +221,38 @@ describe('Breadcrumbs', () => { }) it('should not render Drive component when hasBucket is true', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'test-bucket', breadcrumbs: [], }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles')).not.toBeInTheDocument() }) }) describe('BreadcrumbItem Display', () => { it('should render all breadcrumbs when not collapsed', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], isInPipeline: false, }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('folder1')).toBeInTheDocument() expect(screen.getByText('folder2')).toBeInTheDocument() }) it('should render last breadcrumb as active', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Last breadcrumb should have active styles @@ -319,13 +262,11 @@ describe('Breadcrumbs', () => { }) it('should render non-last breadcrumbs with tertiary styles', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], }) - // Act render(<Breadcrumbs {...props} />) // Assert - First breadcrumb should have tertiary styles @@ -337,14 +278,12 @@ describe('Breadcrumbs', () => { describe('Collapsed Breadcrumbs (Dropdown)', () => { it('should show dropdown when breadcrumbs exceed displayBreadcrumbNum', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4'], isInPipeline: false, // displayBreadcrumbNum = 3 }) - // Act render(<Breadcrumbs {...props} />) // Assert - Dropdown trigger (more button) should be present @@ -352,14 +291,12 @@ describe('Breadcrumbs', () => { }) it('should not show dropdown when breadcrumbs do not exceed displayBreadcrumbNum', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], isInPipeline: false, // displayBreadcrumbNum = 3 }) - // Act const { container } = render(<Breadcrumbs {...props} />) // Assert - Should not have dropdown, just regular breadcrumbs @@ -372,14 +309,12 @@ describe('Breadcrumbs', () => { }) it('should show prefix breadcrumbs and last breadcrumb when collapsed', async () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4', 'folder5'], isInPipeline: false, // displayBreadcrumbNum = 3 }) - // Act render(<Breadcrumbs {...props} />) // Assert - First breadcrumb and last breadcrumb should be visible @@ -392,7 +327,6 @@ describe('Breadcrumbs', () => { }) it('should show collapsed breadcrumbs in dropdown when clicked', async () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4', 'folder5'], @@ -414,17 +348,12 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('breadcrumbs prop', () => { it('should handle empty breadcrumbs array', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: [] }) - // Act render(<Breadcrumbs {...props} />) // Assert - Only Drive should be visible @@ -432,43 +361,34 @@ describe('Breadcrumbs', () => { }) it('should handle single breadcrumb', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['single-folder'] }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('single-folder')).toBeInTheDocument() }) it('should handle breadcrumbs with special characters', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder [1]', 'folder (copy)'], }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('folder [1]')).toBeInTheDocument() expect(screen.getByText('folder (copy)')).toBeInTheDocument() }) it('should handle breadcrumbs with unicode characters', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['文件ć€č', 'ăƒ•ă‚©ăƒ«ăƒ€'], }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('文件ć€č')).toBeInTheDocument() expect(screen.getByText('ăƒ•ă‚©ăƒ«ăƒ€')).toBeInTheDocument() }) @@ -476,27 +396,22 @@ describe('Breadcrumbs', () => { describe('keywords prop', () => { it('should show search results when keywords is non-empty with results', () => { - // Arrange const props = createDefaultProps({ keywords: 'search-term', searchResultsLength: 10, }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText(/searchResult/)).toBeInTheDocument() }) it('should handle whitespace keywords', () => { - // Arrange const props = createDefaultProps({ keywords: ' ', searchResultsLength: 5, }) - // Act render(<Breadcrumbs {...props} />) // Assert - Whitespace is truthy, so should show search results @@ -506,43 +421,35 @@ describe('Breadcrumbs', () => { describe('bucket prop', () => { it('should display bucket name when hasBucket and bucket are set', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'production-bucket', }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('production-bucket')).toBeInTheDocument() }) it('should handle bucket with special characters', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'bucket-v2.0_backup', }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('bucket-v2.0_backup')).toBeInTheDocument() }) }) describe('searchResultsLength prop', () => { it('should handle zero searchResultsLength', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 0, }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should not show search results @@ -550,30 +457,25 @@ describe('Breadcrumbs', () => { }) it('should handle large searchResultsLength', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 10000, }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText(/searchResult.*10000/)).toBeInTheDocument() }) }) describe('isInPipeline prop', () => { it('should use displayBreadcrumbNum=2 when isInPipeline is true', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'], isInPipeline: true, // displayBreadcrumbNum = 2 }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should collapse because 3 > 2 @@ -584,14 +486,12 @@ describe('Breadcrumbs', () => { }) it('should use displayBreadcrumbNum=3 when isInPipeline is false', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'], isInPipeline: false, // displayBreadcrumbNum = 3 }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should NOT collapse because 3 <= 3 @@ -601,7 +501,6 @@ describe('Breadcrumbs', () => { }) it('should reduce displayBreadcrumbNum by 1 when bucket is set', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'], @@ -609,7 +508,6 @@ describe('Breadcrumbs', () => { isInPipeline: false, // displayBreadcrumbNum = 3 - 1 = 2 }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should collapse because 3 > 2 @@ -620,13 +518,10 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== // Memoization Logic and Dependencies Tests - // ========================================== describe('Memoization Logic and Dependencies', () => { describe('displayBreadcrumbNum useMemo', () => { it('should calculate correct value when isInPipeline=false and no bucket', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['a', 'b', 'c', 'd'], @@ -634,7 +529,6 @@ describe('Breadcrumbs', () => { bucket: '', }) - // Act render(<Breadcrumbs {...props} />) // Assert - displayBreadcrumbNum = 3, so 4 breadcrumbs should collapse @@ -646,7 +540,6 @@ describe('Breadcrumbs', () => { }) it('should calculate correct value when isInPipeline=true and no bucket', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['a', 'b', 'c'], @@ -654,7 +547,6 @@ describe('Breadcrumbs', () => { bucket: '', }) - // Act render(<Breadcrumbs {...props} />) // Assert - displayBreadcrumbNum = 2, so 3 breadcrumbs should collapse @@ -664,7 +556,6 @@ describe('Breadcrumbs', () => { }) it('should calculate correct value when isInPipeline=false and bucket exists', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ breadcrumbs: ['a', 'b', 'c'], @@ -672,7 +563,6 @@ describe('Breadcrumbs', () => { bucket: 'my-bucket', }) - // Act render(<Breadcrumbs {...props} />) // Assert - displayBreadcrumbNum = 3 - 1 = 2, so 3 breadcrumbs should collapse @@ -684,7 +574,6 @@ describe('Breadcrumbs', () => { describe('breadcrumbsConfig useMemo', () => { it('should correctly split breadcrumbs when collapsed', async () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['f1', 'f2', 'f3', 'f4', 'f5'], @@ -697,7 +586,6 @@ describe('Breadcrumbs', () => { if (dropdownTrigger) fireEvent.click(dropdownTrigger) - // Assert // prefixBreadcrumbs = ['f1', 'f2'] // collapsedBreadcrumbs = ['f3', 'f4'] // lastBreadcrumb = 'f5' @@ -711,14 +599,12 @@ describe('Breadcrumbs', () => { }) it('should not collapse when breadcrumbs.length <= displayBreadcrumbNum', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['f1', 'f2'], isInPipeline: false, // displayBreadcrumbNum = 3 }) - // Act render(<Breadcrumbs {...props} />) // Assert - All breadcrumbs should be visible @@ -728,13 +614,10 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== // Callback Stability and Event Handlers Tests - // ========================================== describe('Callback Stability and Event Handlers', () => { describe('handleBackToBucketList', () => { it('should reset store state when called', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'my-bucket', @@ -746,7 +629,6 @@ describe('Breadcrumbs', () => { const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]) // Bucket icon button - // Assert expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) expect(mockStoreState.setBucket).toHaveBeenCalledWith('') @@ -757,7 +639,6 @@ describe('Breadcrumbs', () => { describe('handleClickBucketName', () => { it('should reset breadcrumbs and prefix when bucket name is clicked', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'my-bucket', @@ -769,7 +650,6 @@ describe('Breadcrumbs', () => { const bucketButton = screen.getByText('my-bucket') fireEvent.click(bucketButton) - // Assert expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith([]) @@ -777,7 +657,6 @@ describe('Breadcrumbs', () => { }) it('should not call handler when bucket is disabled (no breadcrumbs)', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'my-bucket', @@ -796,7 +675,6 @@ describe('Breadcrumbs', () => { describe('handleBackToRoot', () => { it('should reset state when Drive button is clicked', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1'], @@ -807,7 +685,6 @@ describe('Breadcrumbs', () => { const driveButton = screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles') fireEvent.click(driveButton) - // Assert expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith([]) @@ -817,7 +694,6 @@ describe('Breadcrumbs', () => { describe('handleClickBreadcrumb', () => { it('should slice breadcrumbs and prefix when breadcrumb is clicked', () => { - // Arrange mockStoreState.hasBucket = false mockStoreState.breadcrumbs = ['folder1', 'folder2', 'folder3'] mockStoreState.prefix = ['prefix1', 'prefix2', 'prefix3'] @@ -838,7 +714,6 @@ describe('Breadcrumbs', () => { }) it('should not call handler when last breadcrumb is clicked (disabled)', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], @@ -854,7 +729,6 @@ describe('Breadcrumbs', () => { }) it('should handle click on collapsed breadcrumb from dropdown', async () => { - // Arrange mockStoreState.hasBucket = false mockStoreState.breadcrumbs = ['f1', 'f2', 'f3', 'f4', 'f5'] mockStoreState.prefix = ['p1', 'p2', 'p3', 'p4', 'p5'] @@ -882,17 +756,13 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== // Component Memoization Tests - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Assert expect(Breadcrumbs).toHaveProperty('$$typeof', Symbol.for('react.memo')) }) it('should not re-render when props are the same', () => { - // Arrange const props = createDefaultProps() const { rerender } = render(<Breadcrumbs {...props} />) @@ -905,7 +775,6 @@ describe('Breadcrumbs', () => { }) it('should re-render when breadcrumbs change', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1'] }) const { rerender } = render(<Breadcrumbs {...props} />) @@ -914,32 +783,25 @@ describe('Breadcrumbs', () => { // Act - Rerender with different breadcrumbs rerender(<Breadcrumbs {...createDefaultProps({ breadcrumbs: ['folder2'] })} />) - // Assert expect(screen.getByText('folder2')).toBeInTheDocument() }) }) - // ========================================== // Edge Cases and Error Handling Tests - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle very long breadcrumb names', () => { - // Arrange mockStoreState.hasBucket = false const longName = 'a'.repeat(100) const props = createDefaultProps({ breadcrumbs: [longName], }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle many breadcrumbs', async () => { - // Arrange mockStoreState.hasBucket = false const manyBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`) const props = createDefaultProps({ @@ -962,14 +824,12 @@ describe('Breadcrumbs', () => { }) it('should handle empty bucket string', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: '', breadcrumbs: [], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should show all buckets title @@ -977,13 +837,11 @@ describe('Breadcrumbs', () => { }) it('should handle breadcrumb with only whitespace', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: [' ', 'normal-folder'], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Both should be rendered @@ -991,9 +849,6 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { hasBucket: true, bucket: 'b1', breadcrumbs: [], expected: 'bucket visible' }, @@ -1001,11 +856,9 @@ describe('Breadcrumbs', () => { { hasBucket: false, bucket: '', breadcrumbs: [], expected: 'all files' }, { hasBucket: false, bucket: '', breadcrumbs: ['f1'], expected: 'drive with breadcrumb' }, ])('should render correctly for $expected', ({ hasBucket, bucket, breadcrumbs }) => { - // Arrange mockStoreState.hasBucket = hasBucket const props = createDefaultProps({ bucket, breadcrumbs }) - // Act render(<Breadcrumbs {...props} />) // Assert - Component should render without errors @@ -1019,12 +872,10 @@ describe('Breadcrumbs', () => { { isInPipeline: true, bucket: 'b', expectedNum: 1 }, { isInPipeline: false, bucket: 'b', expectedNum: 2 }, ])('should calculate displayBreadcrumbNum=$expectedNum when isInPipeline=$isInPipeline and bucket=$bucket', ({ isInPipeline, bucket, expectedNum }) => { - // Arrange mockStoreState.hasBucket = !!bucket const breadcrumbs = Array.from({ length: expectedNum + 2 }, (_, i) => `f${i}`) const props = createDefaultProps({ isInPipeline, bucket, breadcrumbs }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should collapse because breadcrumbs.length > expectedNum @@ -1034,12 +885,8 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== - // Integration Tests - // ========================================== describe('Integration', () => { it('should handle full navigation flow: bucket -> folders -> navigation back', () => { - // Arrange mockStoreState.hasBucket = true mockStoreState.breadcrumbs = ['folder1', 'folder2'] mockStoreState.prefix = ['prefix1', 'prefix2'] @@ -1053,13 +900,11 @@ describe('Breadcrumbs', () => { const firstFolder = screen.getByText('folder1') fireEvent.click(firstFolder) - // Assert expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['folder1']) expect(mockStoreState.setPrefix).toHaveBeenCalledWith(['prefix1']) }) it('should handle search result display with navigation elements hidden', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ keywords: 'test', @@ -1068,7 +913,6 @@ describe('Breadcrumbs', () => { breadcrumbs: ['folder1'], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Search result should be shown, navigation elements should be hidden diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/item.spec.tsx new file mode 100644 index 0000000000..f4a63f22b3 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/item.spec.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import BreadcrumbItem from '../item' + +describe('BreadcrumbItem', () => { + const defaultProps = { + name: 'Documents', + index: 2, + handleClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render name', () => { + render(<BreadcrumbItem {...defaultProps} />) + expect(screen.getByText('Documents')).toBeInTheDocument() + }) + + it('should show separator by default', () => { + render(<BreadcrumbItem {...defaultProps} />) + expect(screen.getByText('/')).toBeInTheDocument() + }) + + it('should hide separator when showSeparator is false', () => { + render(<BreadcrumbItem {...defaultProps} showSeparator={false} />) + expect(screen.queryByText('/')).not.toBeInTheDocument() + }) + + it('should call handleClick with index on click', () => { + render(<BreadcrumbItem {...defaultProps} />) + fireEvent.click(screen.getByText('Documents')) + expect(defaultProps.handleClick).toHaveBeenCalledWith(2) + }) + + it('should not call handleClick when disabled', () => { + render(<BreadcrumbItem {...defaultProps} disabled={true} />) + fireEvent.click(screen.getByText('Documents')) + expect(defaultProps.handleClick).not.toHaveBeenCalled() + }) + + it('should apply active styling', () => { + render(<BreadcrumbItem {...defaultProps} isActive={true} />) + const btn = screen.getByRole('button') + expect(btn.className).toContain('system-sm-medium') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx index 13abce1c81..0157d3cf79 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx @@ -1,14 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import Dropdown from './index' +import Dropdown from '../index' -// ========================================== -// Note: react-i18next uses global mock from web/vitest.setup.ts -// ========================================== - -// ========================================== -// Test Data Builders -// ========================================== type DropdownProps = React.ComponentProps<typeof Dropdown> const createDefaultProps = (overrides?: Partial<DropdownProps>): DropdownProps => ({ @@ -18,23 +11,15 @@ const createDefaultProps = (overrides?: Partial<DropdownProps>): DropdownProps = ...overrides, }) -// ========================================== -// Test Suites -// ========================================== describe('Dropdown', () => { beforeEach(() => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<Dropdown {...props} />) // Assert - Trigger button should be visible @@ -42,10 +27,8 @@ describe('Dropdown', () => { }) it('should render trigger button with more icon', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Dropdown {...props} />) // Assert - Button should have RiMoreFill icon (rendered as svg) @@ -55,10 +38,8 @@ describe('Dropdown', () => { }) it('should render separator after dropdown', () => { - // Arrange const props = createDefaultProps() - // Act render(<Dropdown {...props} />) // Assert - Separator "/" should be visible @@ -66,13 +47,10 @@ describe('Dropdown', () => { }) it('should render trigger button with correct default styles', () => { - // Arrange const props = createDefaultProps() - // Act render(<Dropdown {...props} />) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('flex') expect(button).toHaveClass('size-6') @@ -82,10 +60,8 @@ describe('Dropdown', () => { }) it('should not render menu content when closed', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['visible-folder'] }) - // Act render(<Dropdown {...props} />) // Assert - Menu content should not be visible when dropdown is closed @@ -93,7 +69,6 @@ describe('Dropdown', () => { }) it('should render menu content when opened', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['test-folder1', 'test-folder2'] }) render(<Dropdown {...props} />) @@ -108,13 +83,9 @@ describe('Dropdown', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('startIndex prop', () => { it('should pass startIndex to Menu component', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 5, @@ -137,7 +108,6 @@ describe('Dropdown', () => { }) it('should calculate correct index for second item', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 3, @@ -162,16 +132,13 @@ describe('Dropdown', () => { describe('breadcrumbs prop', () => { it('should render all breadcrumbs in menu', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder-a', 'folder-b', 'folder-c'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('folder-a')).toBeInTheDocument() expect(screen.getByText('folder-b')).toBeInTheDocument() @@ -180,29 +147,24 @@ describe('Dropdown', () => { }) it('should handle single breadcrumb', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['single-folder'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('single-folder')).toBeInTheDocument() }) }) it('should handle empty breadcrumbs array', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: [], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - Menu should be rendered but with no items @@ -213,16 +175,13 @@ describe('Dropdown', () => { }) it('should handle breadcrumbs with special characters', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder [1]', 'folder (copy)', 'folder-v2.0'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('folder [1]')).toBeInTheDocument() expect(screen.getByText('folder (copy)')).toBeInTheDocument() @@ -231,16 +190,13 @@ describe('Dropdown', () => { }) it('should handle breadcrumbs with unicode characters', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['文件ć€č', 'ăƒ•ă‚©ăƒ«ăƒ€', 'ПапĐșа'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('文件ć€č')).toBeInTheDocument() expect(screen.getByText('ăƒ•ă‚©ăƒ«ăƒ€')).toBeInTheDocument() @@ -251,7 +207,6 @@ describe('Dropdown', () => { describe('onBreadcrumbClick prop', () => { it('should call onBreadcrumbClick with correct index when item clicked', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 0, @@ -260,7 +215,6 @@ describe('Dropdown', () => { }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -269,23 +223,17 @@ describe('Dropdown', () => { fireEvent.click(screen.getByText('folder1')) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(0) expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1) }) }) }) - // ========================================== - // State Management Tests - // ========================================== describe('State Management', () => { describe('open state', () => { it('should initialize with closed state', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) - // Act render(<Dropdown {...props} />) // Assert - Menu content should not be visible @@ -293,21 +241,17 @@ describe('Dropdown', () => { }) it('should toggle to open state when trigger is clicked', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('test-folder')).toBeInTheDocument() }) }) it('should toggle to closed state when trigger is clicked again', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) render(<Dropdown {...props} />) @@ -319,14 +263,12 @@ describe('Dropdown', () => { fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.queryByText('test-folder')).not.toBeInTheDocument() }) }) it('should close when breadcrumb item is clicked', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ breadcrumbs: ['test-folder'], @@ -341,7 +283,6 @@ describe('Dropdown', () => { expect(screen.getByText('test-folder')).toBeInTheDocument() }) - // Click on breadcrumb item fireEvent.click(screen.getByText('test-folder')) // Assert - Menu should close @@ -351,7 +292,6 @@ describe('Dropdown', () => { }) it('should apply correct button styles based on open state', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) render(<Dropdown {...props} />) const button = screen.getByRole('button') @@ -370,13 +310,10 @@ describe('Dropdown', () => { }) }) - // ========================================== // Event Handlers Tests - // ========================================== describe('Event Handlers', () => { describe('handleTrigger', () => { it('should toggle open state when trigger is clicked', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder'] }) render(<Dropdown {...props} />) @@ -393,7 +330,6 @@ describe('Dropdown', () => { }) it('should toggle multiple times correctly', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder'] }) render(<Dropdown {...props} />) const button = screen.getByRole('button') @@ -421,7 +357,6 @@ describe('Dropdown', () => { describe('handleBreadCrumbClick', () => { it('should call onBreadcrumbClick and close menu', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ breadcrumbs: ['folder1'], @@ -436,10 +371,8 @@ describe('Dropdown', () => { expect(screen.getByText('folder1')).toBeInTheDocument() }) - // Click on breadcrumb fireEvent.click(screen.getByText('folder1')) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1) // Menu should close @@ -449,7 +382,6 @@ describe('Dropdown', () => { }) it('should pass correct index to onBreadcrumbClick for each item', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 2, @@ -473,9 +405,7 @@ describe('Dropdown', () => { }) }) - // ========================================== // Callback Stability and Memoization Tests - // ========================================== describe('Callback Stability and Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - Dropdown component should be memoized @@ -483,7 +413,6 @@ describe('Dropdown', () => { }) it('should maintain stable callback after rerender with same props', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ breadcrumbs: ['folder'], @@ -506,12 +435,10 @@ describe('Dropdown', () => { }) fireEvent.click(screen.getByText('folder')) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(2) }) it('should update callback when onBreadcrumbClick prop changes', async () => { - // Arrange const mockOnBreadcrumbClick1 = vi.fn() const mockOnBreadcrumbClick2 = vi.fn() const props = createDefaultProps({ @@ -543,13 +470,11 @@ describe('Dropdown', () => { }) fireEvent.click(screen.getByText('folder')) - // Assert expect(mockOnBreadcrumbClick1).toHaveBeenCalledTimes(1) expect(mockOnBreadcrumbClick2).toHaveBeenCalledTimes(1) }) it('should not re-render when props are the same', () => { - // Arrange const props = createDefaultProps() const { rerender } = render(<Dropdown {...props} />) @@ -561,12 +486,8 @@ describe('Dropdown', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle rapid toggle clicks', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder'] }) render(<Dropdown {...props} />) const button = screen.getByRole('button') @@ -583,31 +504,26 @@ describe('Dropdown', () => { }) it('should handle very long folder names', async () => { - // Arrange const longName = 'a'.repeat(100) const props = createDefaultProps({ breadcrumbs: [longName], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText(longName)).toBeInTheDocument() }) }) it('should handle many breadcrumbs', async () => { - // Arrange const manyBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`) const props = createDefaultProps({ breadcrumbs: manyBreadcrumbs, }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - First and last items should be visible @@ -618,7 +534,6 @@ describe('Dropdown', () => { }) it('should handle startIndex of 0', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 0, @@ -627,19 +542,16 @@ describe('Dropdown', () => { }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) await waitFor(() => { expect(screen.getByText('folder')).toBeInTheDocument() }) fireEvent.click(screen.getByText('folder')) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(0) }) it('should handle large startIndex values', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 999, @@ -648,53 +560,42 @@ describe('Dropdown', () => { }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) await waitFor(() => { expect(screen.getByText('folder')).toBeInTheDocument() }) fireEvent.click(screen.getByText('folder')) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(999) }) it('should handle breadcrumbs with whitespace-only names', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: [' ', 'normal-folder'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('normal-folder')).toBeInTheDocument() }) }) it('should handle breadcrumbs with empty string', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['', 'folder'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('folder')).toBeInTheDocument() }) }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { startIndex: 0, breadcrumbs: ['a'], expectedIndex: 0 }, @@ -702,7 +603,6 @@ describe('Dropdown', () => { { startIndex: 5, breadcrumbs: ['a'], expectedIndex: 5 }, { startIndex: 10, breadcrumbs: ['a', 'b'], expectedIndex: 10 }, ])('should handle startIndex=$startIndex correctly', async ({ startIndex, breadcrumbs, expectedIndex }) => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex, @@ -711,14 +611,12 @@ describe('Dropdown', () => { }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) await waitFor(() => { expect(screen.getByText(breadcrumbs[0])).toBeInTheDocument() }) fireEvent.click(screen.getByText(breadcrumbs[0])) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(expectedIndex) }) @@ -728,10 +626,8 @@ describe('Dropdown', () => { { breadcrumbs: ['a', 'b'], description: 'two items' }, { breadcrumbs: ['a', 'b', 'c', 'd', 'e'], description: 'five items' }, ])('should render correctly with $description breadcrumbs', async ({ breadcrumbs }) => { - // Arrange const props = createDefaultProps({ breadcrumbs }) - // Act render(<Dropdown {...props} />) fireEvent.click(screen.getByRole('button')) @@ -743,21 +639,16 @@ describe('Dropdown', () => { }) }) - // ========================================== // Integration Tests (Menu and Item) - // ========================================== describe('Integration with Menu and Item', () => { it('should render all menu items with correct content', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['Documents', 'Projects', 'Archive'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('Documents')).toBeInTheDocument() expect(screen.getByText('Projects')).toBeInTheDocument() @@ -766,7 +657,6 @@ describe('Dropdown', () => { }) it('should handle click on any menu item', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 0, @@ -787,7 +677,6 @@ describe('Dropdown', () => { }) it('should close menu after any item click', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ breadcrumbs: ['item1', 'item2', 'item3'], @@ -811,7 +700,6 @@ describe('Dropdown', () => { }) it('should correctly calculate index for each item based on startIndex', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 3, @@ -836,31 +724,22 @@ describe('Dropdown', () => { }) }) - // ========================================== - // Accessibility Tests - // ========================================== describe('Accessibility', () => { it('should render trigger as button element', () => { - // Arrange const props = createDefaultProps() - // Act render(<Dropdown {...props} />) - // Assert const button = screen.getByRole('button') expect(button).toBeInTheDocument() expect(button.tagName).toBe('BUTTON') }) it('should have type="button" attribute', () => { - // Arrange const props = createDefaultProps() - // Act render(<Dropdown {...props} />) - // Assert const button = screen.getByRole('button') expect(button).toHaveAttribute('type', 'button') }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx new file mode 100644 index 0000000000..4437305ad4 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Item from '../item' + +describe('Item', () => { + const defaultProps = { + name: 'Documents', + index: 2, + onBreadcrumbClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verify the breadcrumb name is displayed + describe('Rendering', () => { + it('should render breadcrumb name', () => { + render(<Item {...defaultProps} />) + + expect(screen.getByText('Documents')).toBeInTheDocument() + }) + }) + + // User interactions: clicking triggers callback with correct index + describe('User Interactions', () => { + it('should call onBreadcrumbClick with correct index on click', () => { + render(<Item {...defaultProps} />) + + fireEvent.click(screen.getByText('Documents')) + + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce() + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2) + }) + + it('should pass different index values correctly', () => { + render(<Item {...defaultProps} index={5} />) + + fireEvent.click(screen.getByText('Documents')) + + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(5) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx new file mode 100644 index 0000000000..c8c6b8fec3 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Menu from '../menu' + +describe('Menu', () => { + const defaultProps = { + breadcrumbs: ['Folder A', 'Folder B', 'Folder C'], + startIndex: 1, + onBreadcrumbClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verify all breadcrumb items are displayed + describe('Rendering', () => { + it('should render all breadcrumb items', () => { + render(<Menu {...defaultProps} />) + + expect(screen.getByText('Folder A')).toBeInTheDocument() + expect(screen.getByText('Folder B')).toBeInTheDocument() + expect(screen.getByText('Folder C')).toBeInTheDocument() + }) + + it('should render empty list when no breadcrumbs provided', () => { + const { container } = render( + <Menu breadcrumbs={[]} startIndex={0} onBreadcrumbClick={vi.fn()} />, + ) + + const menuContainer = container.firstElementChild + expect(menuContainer?.children).toHaveLength(0) + }) + }) + + // Index mapping: startIndex offsets are applied correctly + describe('Index Mapping', () => { + it('should pass correct index (startIndex + offset) to each item', () => { + render(<Menu {...defaultProps} />) + + fireEvent.click(screen.getByText('Folder A')) + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1) + + fireEvent.click(screen.getByText('Folder B')) + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2) + + fireEvent.click(screen.getByText('Folder C')) + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(3) + }) + + it('should offset from startIndex of zero', () => { + render( + <Menu + breadcrumbs={['First', 'Second']} + startIndex={0} + onBreadcrumbClick={defaultProps.onBreadcrumbClick} + />, + ) + + fireEvent.click(screen.getByText('First')) + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(0) + + fireEvent.click(screen.getByText('Second')) + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1) + }) + }) + + // User interactions: clicking items triggers the callback + describe('User Interactions', () => { + it('should call onBreadcrumbClick with correct index when item clicked', () => { + render(<Menu {...defaultProps} />) + + fireEvent.click(screen.getByText('Folder B')) + + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce() + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-folder.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-folder.spec.tsx new file mode 100644 index 0000000000..8d026d5589 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-folder.spec.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import EmptyFolder from '../empty-folder' + +describe('EmptyFolder', () => { + it('should render empty folder message', () => { + render(<EmptyFolder />) + expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx new file mode 100644 index 0000000000..8b88a939e8 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx @@ -0,0 +1,31 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import EmptySearchResult from '../empty-search-result' + +vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({ + SearchMenu: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="search-icon" {...props} />, +})) + +describe('EmptySearchResult', () => { + const onResetKeywords = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render empty state message', () => { + render(<EmptySearchResult onResetKeywords={onResetKeywords} />) + expect(screen.getByText('datasetPipeline.onlineDrive.emptySearchResult')).toBeInTheDocument() + }) + + it('should render reset button', () => { + render(<EmptySearchResult onResetKeywords={onResetKeywords} />) + expect(screen.getByText('datasetPipeline.onlineDrive.resetKeywords')).toBeInTheDocument() + }) + + it('should call onResetKeywords when reset button clicked', () => { + render(<EmptySearchResult onResetKeywords={onResetKeywords} />) + fireEvent.click(screen.getByText('datasetPipeline.onlineDrive.resetKeywords')) + expect(onResetKeywords).toHaveBeenCalledOnce() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx new file mode 100644 index 0000000000..3377d4099d --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { OnlineDriveFileType } from '@/models/pipeline' +import FileIcon from '../file-icon' + +vi.mock('@/app/components/base/file-uploader/file-type-icon', () => ({ + default: ({ type }: { type: string }) => <span data-testid="file-type-icon">{type}</span>, +})) +vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({ + BucketsBlue: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="bucket-icon" {...props} />, + Folder: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="folder-icon" {...props} />, +})) + +describe('FileIcon', () => { + it('should render bucket icon for bucket type', () => { + render(<FileIcon type={OnlineDriveFileType.bucket} fileName="" />) + expect(screen.getByTestId('bucket-icon')).toBeInTheDocument() + }) + + it('should render folder icon for folder type', () => { + render(<FileIcon type={OnlineDriveFileType.folder} fileName="" />) + expect(screen.getByTestId('folder-icon')).toBeInTheDocument() + }) + + it('should render file type icon for file type', () => { + render(<FileIcon type={OnlineDriveFileType.file} fileName="doc.pdf" />) + expect(screen.getByTestId('file-type-icon')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/index.spec.tsx similarity index 92% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/index.spec.tsx index 0a8066bdc7..921bf7e207 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/index.spec.tsx @@ -3,16 +3,10 @@ import type { OnlineDriveFile } from '@/models/pipeline' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { OnlineDriveFileType } from '@/models/pipeline' -import List from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import List from '../index' // Mock Item component for List tests - child component with complex behavior -vi.mock('./item', () => ({ +vi.mock('../item', () => ({ default: ({ file, isSelected, onSelect, onOpen, isMultipleChoice }: { file: OnlineDriveFile isSelected: boolean @@ -35,14 +29,14 @@ vi.mock('./item', () => ({ })) // Mock EmptyFolder component for List tests -vi.mock('./empty-folder', () => ({ +vi.mock('../empty-folder', () => ({ default: () => ( <div data-testid="empty-folder">Empty Folder</div> ), })) // Mock EmptySearchResult component for List tests -vi.mock('./empty-search-result', () => ({ +vi.mock('../empty-search-result', () => ({ default: ({ onResetKeywords }: { onResetKeywords: () => void }) => ( <div data-testid="empty-search-result"> <span>No results</span> @@ -53,7 +47,7 @@ vi.mock('./empty-search-result', () => ({ // Mock store state and refs const mockIsTruncated = { current: false } -const mockCurrentNextPageParametersRef = { current: {} as Record<string, any> } +const mockCurrentNextPageParametersRef = { current: {} as Record<string, unknown> } const mockSetNextPageParameters = vi.fn() const mockStoreState = { @@ -65,13 +59,10 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../../../store', () => ({ +vi.mock('../../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, })) -// ========================================== -// Test Data Builders -// ========================================== const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({ id: 'file-1', name: 'test-file.txt', @@ -102,9 +93,7 @@ const createDefaultProps = (overrides?: Partial<ListProps>): ListProps => ({ ...overrides, }) -// ========================================== // Mock IntersectionObserver -// ========================================== let mockIntersectionObserverCallback: IntersectionObserverCallback | null = null let mockIntersectionObserverInstance: { observe: Mock @@ -136,9 +125,6 @@ const createMockIntersectionObserver = () => { } } -// ========================================== -// Helper Functions -// ========================================== const triggerIntersection = (isIntersecting: boolean) => { if (mockIntersectionObserverCallback) { const entries = [{ @@ -161,9 +147,6 @@ const resetMockStoreState = () => { mockGetState.mockClear() } -// ========================================== -// Test Suites -// ========================================== describe('List', () => { const originalIntersectionObserver = window.IntersectionObserver @@ -181,89 +164,69 @@ describe('List', () => { window.IntersectionObserver = originalIntersectionObserver }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<List {...props} />) - // Assert expect(document.body).toBeInTheDocument() }) it('should render Loading component when isAllLoading is true', () => { - // Arrange const props = createDefaultProps({ isLoading: true, fileList: [], keywords: '', }) - // Act render(<List {...props} />) - // Assert expect(screen.getByRole('status')).toBeInTheDocument() }) it('should render EmptyFolder when folder is empty and not loading', () => { - // Arrange const props = createDefaultProps({ isLoading: false, fileList: [], keywords: '', }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-folder')).toBeInTheDocument() }) it('should render EmptySearchResult when search has no results', () => { - // Arrange const props = createDefaultProps({ isLoading: false, fileList: [], keywords: 'non-existent-file', }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-search-result')).toBeInTheDocument() }) it('should render file list when files exist', () => { - // Arrange const fileList = createMockFileList(3) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() expect(screen.getByTestId('item-file-2')).toBeInTheDocument() expect(screen.getByTestId('item-file-3')).toBeInTheDocument() }) it('should render partial loading spinner when loading more files', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, isLoading: true, }) - // Act render(<List {...props} />) // Assert - Should show files AND loading indicator @@ -272,20 +235,14 @@ describe('List', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('fileList prop', () => { it('should render all files from fileList', () => { - // Arrange const fileList = createMockFileList(5) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert fileList.forEach((file) => { expect(screen.getByTestId(`item-${file.id}`)).toBeInTheDocument() expect(screen.getByTestId(`item-name-${file.id}`)).toHaveTextContent(file.name) @@ -293,37 +250,28 @@ describe('List', () => { }) it('should handle empty fileList', () => { - // Arrange const props = createDefaultProps({ fileList: [] }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-folder')).toBeInTheDocument() }) it('should handle single file in fileList', () => { - // Arrange const fileList = [createMockOnlineDriveFile()] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() }) it('should handle large fileList', () => { - // Arrange const fileList = createMockFileList(100) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() expect(screen.getByTestId('item-file-100')).toBeInTheDocument() }) @@ -331,51 +279,42 @@ describe('List', () => { describe('selectedFileIds prop', () => { it('should mark selected files as selected', () => { - // Arrange const fileList = createMockFileList(3) const props = createDefaultProps({ fileList, selectedFileIds: ['file-1', 'file-3'], }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'true') expect(screen.getByTestId('item-file-2')).toHaveAttribute('data-selected', 'false') expect(screen.getByTestId('item-file-3')).toHaveAttribute('data-selected', 'true') }) it('should handle empty selectedFileIds', () => { - // Arrange const fileList = createMockFileList(3) const props = createDefaultProps({ fileList, selectedFileIds: [], }) - // Act render(<List {...props} />) - // Assert fileList.forEach((file) => { expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', 'false') }) }) it('should handle all files selected', () => { - // Arrange const fileList = createMockFileList(3) const props = createDefaultProps({ fileList, selectedFileIds: ['file-1', 'file-2', 'file-3'], }) - // Act render(<List {...props} />) - // Assert fileList.forEach((file) => { expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', 'true') }) @@ -384,30 +323,24 @@ describe('List', () => { describe('keywords prop', () => { it('should show EmptySearchResult when keywords exist but no results', () => { - // Arrange const props = createDefaultProps({ fileList: [], keywords: 'search-term', }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-search-result')).toBeInTheDocument() }) it('should show EmptyFolder when keywords is empty and no files', () => { - // Arrange const props = createDefaultProps({ fileList: [], keywords: '', }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-folder')).toBeInTheDocument() }) }) @@ -419,13 +352,10 @@ describe('List', () => { { isLoading: false, fileList: [], keywords: '', expected: 'isEmpty' }, { isLoading: false, fileList: createMockFileList(2), keywords: '', expected: 'hasFiles' }, ])('should render correctly when isLoading=$isLoading with fileList.length=$fileList.length', ({ isLoading, fileList, expected }) => { - // Arrange const props = createDefaultProps({ isLoading, fileList }) - // Act render(<List {...props} />) - // Assert switch (expected) { case 'isAllLoading': expect(screen.getByRole('status')).toBeInTheDocument() @@ -446,44 +376,35 @@ describe('List', () => { describe('supportBatchUpload prop', () => { it('should pass supportBatchUpload true to Item components', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, supportBatchUpload: true, }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-multiple-choice', 'true') }) it('should pass supportBatchUpload false to Item components', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, supportBatchUpload: false, }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-multiple-choice', 'false') }) }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions', () => { describe('File Selection', () => { it('should call handleSelectFile when selecting a file', () => { - // Arrange const handleSelectFile = vi.fn() const fileList = createMockFileList(2) const props = createDefaultProps({ @@ -492,15 +413,12 @@ describe('List', () => { }) render(<List {...props} />) - // Act fireEvent.click(screen.getByTestId('item-select-file-1')) - // Assert expect(handleSelectFile).toHaveBeenCalledWith(fileList[0]) }) it('should call handleSelectFile with correct file data', () => { - // Arrange const handleSelectFile = vi.fn() const fileList = [ createMockOnlineDriveFile({ id: 'unique-id', name: 'special-file.pdf', size: 5000 }), @@ -511,10 +429,8 @@ describe('List', () => { }) render(<List {...props} />) - // Act fireEvent.click(screen.getByTestId('item-select-unique-id')) - // Assert expect(handleSelectFile).toHaveBeenCalledWith( expect.objectContaining({ id: 'unique-id', @@ -527,7 +443,6 @@ describe('List', () => { describe('Folder Navigation', () => { it('should call handleOpenFolder when opening a folder', () => { - // Arrange const handleOpenFolder = vi.fn() const fileList = [ createMockOnlineDriveFile({ id: 'folder-1', name: 'Documents', type: OnlineDriveFileType.folder }), @@ -538,17 +453,14 @@ describe('List', () => { }) render(<List {...props} />) - // Act fireEvent.click(screen.getByTestId('item-open-folder-1')) - // Assert expect(handleOpenFolder).toHaveBeenCalledWith(fileList[0]) }) }) describe('Reset Keywords', () => { it('should call handleResetKeywords when reset button is clicked', () => { - // Arrange const handleResetKeywords = vi.fn() const props = createDefaultProps({ fileList: [], @@ -557,38 +469,29 @@ describe('List', () => { }) render(<List {...props} />) - // Act fireEvent.click(screen.getByTestId('reset-keywords-btn')) - // Assert expect(handleResetKeywords).toHaveBeenCalledTimes(1) }) }) }) - // ========================================== // Side Effects and Cleanup Tests (IntersectionObserver) - // ========================================== describe('Side Effects and Cleanup', () => { describe('IntersectionObserver Setup', () => { it('should create IntersectionObserver on mount', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(mockIntersectionObserverInstance?.observe).toHaveBeenCalled() }) it('should create IntersectionObserver with correct rootMargin', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) // Assert - Callback should be set @@ -596,14 +499,11 @@ describe('List', () => { }) it('should observe the anchor element', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(mockIntersectionObserverInstance?.observe).toHaveBeenCalled() const observedElement = mockIntersectionObserverInstance?.observe.mock.calls[0]?.[0] expect(observedElement).toBeInstanceOf(HTMLElement) @@ -613,7 +513,6 @@ describe('List', () => { describe('IntersectionObserver Callback', () => { it('should call setNextPageParameters when intersecting and truncated', async () => { - // Arrange mockIsTruncated.current = true mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' } const fileList = createMockFileList(2) @@ -623,17 +522,14 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(true) - // Assert await waitFor(() => { expect(mockSetNextPageParameters).toHaveBeenCalledWith({ cursor: 'next-cursor' }) }) }) it('should not call setNextPageParameters when not intersecting', () => { - // Arrange mockIsTruncated.current = true mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' } const fileList = createMockFileList(2) @@ -643,15 +539,12 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(false) - // Assert expect(mockSetNextPageParameters).not.toHaveBeenCalled() }) it('should not call setNextPageParameters when not truncated', () => { - // Arrange mockIsTruncated.current = false const fileList = createMockFileList(2) const props = createDefaultProps({ @@ -660,15 +553,12 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(true) - // Assert expect(mockSetNextPageParameters).not.toHaveBeenCalled() }) it('should not call setNextPageParameters when loading', () => { - // Arrange mockIsTruncated.current = true mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' } const fileList = createMockFileList(2) @@ -678,30 +568,24 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(true) - // Assert expect(mockSetNextPageParameters).not.toHaveBeenCalled() }) }) describe('IntersectionObserver Cleanup', () => { it('should disconnect IntersectionObserver on unmount', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList }) const { unmount } = render(<List {...props} />) - // Act unmount() - // Assert expect(mockIntersectionObserverInstance?.disconnect).toHaveBeenCalled() }) it('should cleanup previous observer when dependencies change', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, @@ -718,18 +602,14 @@ describe('List', () => { }) }) - // ========================================== // Component Memoization Tests - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange & Assert // List component should have $$typeof symbol indicating memo wrapper expect(List).toHaveProperty('$$typeof', Symbol.for('react.memo')) }) it('should not re-render when props are equal', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList }) const renderSpy = vi.fn() @@ -751,7 +631,6 @@ describe('List', () => { }) it('should re-render when fileList changes', () => { - // Arrange const fileList1 = createMockFileList(2) const fileList2 = createMockFileList(3) const props1 = createDefaultProps({ fileList: fileList1 }) @@ -772,7 +651,6 @@ describe('List', () => { }) it('should re-render when selectedFileIds changes', () => { - // Arrange const fileList = createMockFileList(2) const props1 = createDefaultProps({ fileList, selectedFileIds: [] }) const props2 = createDefaultProps({ fileList, selectedFileIds: ['file-1'] }) @@ -782,15 +660,12 @@ describe('List', () => { // Assert initial state expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'false') - // Act rerender(<List {...props2} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'true') }) it('should re-render when isLoading changes', () => { - // Arrange const fileList = createMockFileList(2) const props1 = createDefaultProps({ fileList, isLoading: false }) const props2 = createDefaultProps({ fileList, isLoading: true }) @@ -800,7 +675,6 @@ describe('List', () => { // Assert initial state - no loading spinner expect(screen.queryByRole('status')).not.toBeInTheDocument() - // Act rerender(<List {...props2} />) // Assert - loading spinner should appear @@ -808,45 +682,34 @@ describe('List', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { describe('Empty/Null Values', () => { it('should handle empty fileList array', () => { - // Arrange const props = createDefaultProps({ fileList: [] }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-folder')).toBeInTheDocument() }) it('should handle empty selectedFileIds array', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, selectedFileIds: [], }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'false') }) it('should handle empty keywords string', () => { - // Arrange const props = createDefaultProps({ fileList: [], keywords: '', }) - // Act render(<List {...props} />) // Assert - Shows empty folder, not empty search result @@ -857,65 +720,50 @@ describe('List', () => { describe('Boundary Conditions', () => { it('should handle very long file names', () => { - // Arrange const longName = `${'a'.repeat(500)}.txt` const fileList = [createMockOnlineDriveFile({ name: longName })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(longName) }) it('should handle special characters in file names', () => { - // Arrange const specialName = 'test<script>alert("xss")</script>.txt' const fileList = [createMockOnlineDriveFile({ name: specialName })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(specialName) }) it('should handle unicode characters in file names', () => { - // Arrange const unicodeName = '文件_📁_ăƒ•ă‚Ąă‚€ăƒ«.txt' const fileList = [createMockOnlineDriveFile({ name: unicodeName })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(unicodeName) }) it('should handle file with zero size', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ size: 0 })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() }) it('should handle file with undefined size', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ size: undefined })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() }) }) @@ -926,20 +774,16 @@ describe('List', () => { { type: OnlineDriveFileType.folder, name: 'Documents' }, { type: OnlineDriveFileType.bucket, name: 'my-bucket' }, ])('should render $type type correctly', ({ type, name }) => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: `item-${type}`, type, name })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId(`item-item-${type}`)).toBeInTheDocument() expect(screen.getByTestId(`item-name-item-${type}`)).toHaveTextContent(name) }) it('should handle mixed file types in list', () => { - // Arrange const fileList = [ createMockOnlineDriveFile({ id: 'file-1', type: OnlineDriveFileType.file, name: 'doc.pdf' }), createMockOnlineDriveFile({ id: 'folder-1', type: OnlineDriveFileType.folder, name: 'Documents' }), @@ -947,10 +791,8 @@ describe('List', () => { ] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() expect(screen.getByTestId('item-folder-1')).toBeInTheDocument() expect(screen.getByTestId('item-bucket-1')).toBeInTheDocument() @@ -959,7 +801,6 @@ describe('List', () => { describe('Loading States Transitions', () => { it('should transition from loading to empty folder', () => { - // Arrange const props1 = createDefaultProps({ isLoading: true, fileList: [] }) const props2 = createDefaultProps({ isLoading: false, fileList: [] }) @@ -968,16 +809,13 @@ describe('List', () => { // Assert initial loading state expect(screen.getByRole('status')).toBeInTheDocument() - // Act rerender(<List {...props2} />) - // Assert expect(screen.queryByRole('status')).not.toBeInTheDocument() expect(screen.getByTestId('empty-folder')).toBeInTheDocument() }) it('should transition from loading to file list', () => { - // Arrange const fileList = createMockFileList(2) const props1 = createDefaultProps({ isLoading: true, fileList: [] }) const props2 = createDefaultProps({ isLoading: false, fileList }) @@ -987,16 +825,13 @@ describe('List', () => { // Assert initial loading state expect(screen.getByRole('status')).toBeInTheDocument() - // Act rerender(<List {...props2} />) - // Assert expect(screen.queryByRole('status')).not.toBeInTheDocument() expect(screen.getByTestId('item-file-1')).toBeInTheDocument() }) it('should transition from partial loading to loaded', () => { - // Arrange const fileList = createMockFileList(2) const props1 = createDefaultProps({ isLoading: true, fileList }) const props2 = createDefaultProps({ isLoading: false, fileList }) @@ -1006,17 +841,14 @@ describe('List', () => { // Assert initial partial loading state expect(screen.getByRole('status')).toBeInTheDocument() - // Act rerender(<List {...props2} />) - // Assert expect(screen.queryByRole('status')).not.toBeInTheDocument() }) }) describe('Store State Edge Cases', () => { it('should handle store state with empty next page parameters', () => { - // Arrange mockIsTruncated.current = true mockCurrentNextPageParametersRef.current = {} const fileList = createMockFileList(2) @@ -1026,15 +858,12 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(true) - // Assert expect(mockSetNextPageParameters).toHaveBeenCalledWith({}) }) it('should handle store state with complex next page parameters', () => { - // Arrange const complexParams = { cursor: 'abc123', page: 2, @@ -1049,31 +878,23 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(true) - // Assert expect(mockSetNextPageParameters).toHaveBeenCalledWith(complexParams) }) }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { supportBatchUpload: true }, { supportBatchUpload: false }, ])('should render correctly with supportBatchUpload=$supportBatchUpload', ({ supportBatchUpload }) => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, supportBatchUpload }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute( 'data-multiple-choice', String(supportBatchUpload), @@ -1087,14 +908,11 @@ describe('List', () => { { isLoading: false, fileCount: 0, keywords: 'search', expectedState: 'empty-search' }, { isLoading: false, fileCount: 5, keywords: '', expectedState: 'file-list' }, ])('should render $expectedState when isLoading=$isLoading, fileCount=$fileCount, keywords=$keywords', ({ isLoading, fileCount, keywords, expectedState }) => { - // Arrange const fileList = createMockFileList(fileCount) const props = createDefaultProps({ fileList, isLoading, keywords }) - // Act render(<List {...props} />) - // Assert switch (expectedState) { case 'all-loading': expect(screen.getByRole('status')).toBeInTheDocument() @@ -1120,17 +938,14 @@ describe('List', () => { { selectedCount: 1, expectedSelected: ['file-1'] }, { selectedCount: 3, expectedSelected: ['file-1', 'file-2', 'file-3'] }, ])('should handle $selectedCount selected files', ({ expectedSelected }) => { - // Arrange const fileList = createMockFileList(3) const props = createDefaultProps({ fileList, selectedFileIds: expectedSelected, }) - // Act render(<List {...props} />) - // Assert fileList.forEach((file) => { const isSelected = expectedSelected.includes(file.id) expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', String(isSelected)) @@ -1138,12 +953,8 @@ describe('List', () => { }) }) - // ========================================== - // Accessibility Tests - // ========================================== describe('Accessibility', () => { it('should allow interaction with reset keywords button in empty search state', () => { - // Arrange const handleResetKeywords = vi.fn() const props = createDefaultProps({ fileList: [], @@ -1151,11 +962,9 @@ describe('List', () => { handleResetKeywords, }) - // Act render(<List {...props} />) const resetButton = screen.getByTestId('reset-keywords-btn') - // Assert expect(resetButton).toBeInTheDocument() fireEvent.click(resetButton) expect(handleResetKeywords).toHaveBeenCalled() @@ -1163,15 +972,13 @@ describe('List', () => { }) }) -// ========================================== // EmptyFolder Component Tests (using actual component) -// ========================================== describe('EmptyFolder', () => { // Get real component for testing let ActualEmptyFolder: React.ComponentType beforeAll(async () => { - const mod = await vi.importActual<{ default: React.ComponentType }>('./empty-folder') + const mod = await vi.importActual<{ default: React.ComponentType }>('../empty-folder') ActualEmptyFolder = mod.default }) @@ -1206,15 +1013,13 @@ describe('EmptyFolder', () => { }) }) -// ========================================== // EmptySearchResult Component Tests (using actual component) -// ========================================== describe('EmptySearchResult', () => { // Get real component for testing let ActualEmptySearchResult: React.ComponentType<{ onResetKeywords: () => void }> beforeAll(async () => { - const mod = await vi.importActual<{ default: React.ComponentType<{ onResetKeywords: () => void }> }>('./empty-search-result') + const mod = await vi.importActual<{ default: React.ComponentType<{ onResetKeywords: () => void }> }>('../empty-search-result') ActualEmptySearchResult = mod.default }) @@ -1292,16 +1097,14 @@ describe('EmptySearchResult', () => { }) }) -// ========================================== // FileIcon Component Tests (using actual component) -// ========================================== describe('FileIcon', () => { // Get real component for testing type FileIconProps = { type: OnlineDriveFileType, fileName: string, size?: 'sm' | 'md' | 'lg' | 'xl', className?: string } let ActualFileIcon: React.ComponentType<FileIconProps> beforeAll(async () => { - const mod = await vi.importActual<{ default: React.ComponentType<FileIconProps> }>('./file-icon') + const mod = await vi.importActual<{ default: React.ComponentType<FileIconProps> }>('../file-icon') ActualFileIcon = mod.default }) @@ -1455,9 +1258,7 @@ describe('FileIcon', () => { }) }) -// ========================================== // Item Component Tests (using actual component) -// ========================================== describe('Item', () => { // Get real component for testing let ActualItem: React.ComponentType<ItemProps> @@ -1472,7 +1273,7 @@ describe('Item', () => { } beforeAll(async () => { - const mod = await vi.importActual<{ default: React.ComponentType<ItemProps> }>('./item') + const mod = await vi.importActual<{ default: React.ComponentType<ItemProps> }>('../item') ActualItem = mod.default }) @@ -1746,9 +1547,7 @@ describe('Item', () => { }) }) -// ========================================== // Utils Tests -// ========================================== describe('utils', () => { // Import actual utils functions let getFileExtension: (filename: string) => string @@ -1756,7 +1555,7 @@ describe('utils', () => { let FileAppearanceTypeEnum: Record<string, string> beforeAll(async () => { - const utils = await vi.importActual<{ getFileExtension: typeof getFileExtension, getFileType: typeof getFileType }>('./utils') + const utils = await vi.importActual<{ getFileExtension: typeof getFileExtension, getFileType: typeof getFileType }>('../utils') const types = await vi.importActual<{ FileAppearanceTypeEnum: typeof FileAppearanceTypeEnum }>('@/app/components/base/file-uploader/types') getFileExtension = utils.getFileExtension getFileType = utils.getFileType diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/item.spec.tsx new file mode 100644 index 0000000000..5da25e5cb0 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/item.spec.tsx @@ -0,0 +1,90 @@ +import type { OnlineDriveFile } from '@/models/pipeline' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Item from '../item' + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ checked, onCheck, disabled }: { checked: boolean, onCheck: () => void, disabled?: boolean }) => ( + <input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} disabled={disabled} /> + ), +})) + +vi.mock('@/app/components/base/radio/ui', () => ({ + default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => ( + <input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} /> + ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( + <div data-testid="tooltip" title={popupContent}>{children}</div> + ), +})) + +vi.mock('../file-icon', () => ({ + default: () => <span data-testid="file-icon" />, +})) + +describe('Item', () => { + const makeFile = (type: string, name = 'test.pdf', size = 1024): OnlineDriveFile => ({ + id: 'f-1', + name, + type: type as OnlineDriveFile['type'], + size, + }) + + const defaultProps = { + file: makeFile('file'), + isSelected: false, + onSelect: vi.fn(), + onOpen: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render file name', () => { + render(<Item {...defaultProps} />) + expect(screen.getByText('test.pdf')).toBeInTheDocument() + }) + + it('should render checkbox for file type in multiple choice mode', () => { + render(<Item {...defaultProps} />) + expect(screen.getByTestId('checkbox')).toBeInTheDocument() + }) + + it('should render radio for file type in single choice mode', () => { + render(<Item {...defaultProps} isMultipleChoice={false} />) + expect(screen.getByTestId('radio')).toBeInTheDocument() + }) + + it('should not render checkbox for bucket type', () => { + render(<Item {...defaultProps} file={makeFile('bucket', 'my-bucket')} />) + expect(screen.queryByTestId('checkbox')).not.toBeInTheDocument() + }) + + it('should call onOpen for folder click', () => { + const file = makeFile('folder', 'my-folder') + render(<Item {...defaultProps} file={file} />) + fireEvent.click(screen.getByText('my-folder')) + expect(defaultProps.onOpen).toHaveBeenCalledWith(file) + }) + + it('should call onSelect for file click', () => { + render(<Item {...defaultProps} />) + fireEvent.click(screen.getByText('test.pdf')) + expect(defaultProps.onSelect).toHaveBeenCalledWith(defaultProps.file) + }) + + it('should not call handlers when disabled', () => { + render(<Item {...defaultProps} disabled={true} />) + fireEvent.click(screen.getByText('test.pdf')) + expect(defaultProps.onSelect).not.toHaveBeenCalled() + }) + + it('should render file icon', () => { + render(<Item {...defaultProps} />) + expect(screen.getByTestId('file-icon')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/utils.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/utils.spec.ts new file mode 100644 index 0000000000..982e57a1d0 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/utils.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' +import { getFileExtension, getFileType } from '../utils' + +describe('getFileExtension', () => { + it('should return extension for normal file', () => { + expect(getFileExtension('test.pdf')).toBe('pdf') + }) + + it('should return lowercase extension', () => { + expect(getFileExtension('test.PDF')).toBe('pdf') + }) + + it('should return last extension for multiple dots', () => { + expect(getFileExtension('my.file.name.txt')).toBe('txt') + }) + + it('should return empty string for no extension', () => { + expect(getFileExtension('noext')).toBe('') + }) + + it('should return empty string for empty string', () => { + expect(getFileExtension('')).toBe('') + }) + + it('should return empty string for dotfile with no extension', () => { + expect(getFileExtension('.gitignore')).toBe('') + }) +}) + +describe('getFileType', () => { + it('should return pdf for .pdf files', () => { + expect(getFileType('doc.pdf')).toBe(FileAppearanceTypeEnum.pdf) + }) + + it('should return markdown for .md files', () => { + expect(getFileType('readme.md')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return markdown for .mdx files', () => { + expect(getFileType('page.mdx')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return excel for .xlsx files', () => { + expect(getFileType('data.xlsx')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return excel for .csv files', () => { + expect(getFileType('data.csv')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return word for .docx files', () => { + expect(getFileType('doc.docx')).toBe(FileAppearanceTypeEnum.word) + }) + + it('should return ppt for .pptx files', () => { + expect(getFileType('slides.pptx')).toBe(FileAppearanceTypeEnum.ppt) + }) + + it('should return code for .html files', () => { + expect(getFileType('page.html')).toBe(FileAppearanceTypeEnum.code) + }) + + it('should return code for .json files', () => { + expect(getFileType('config.json')).toBe(FileAppearanceTypeEnum.code) + }) + + it('should return gif for .gif files', () => { + expect(getFileType('animation.gif')).toBe(FileAppearanceTypeEnum.gif) + }) + + it('should return custom for unknown extension', () => { + expect(getFileType('file.xyz')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom for no extension', () => { + expect(getFileType('noext')).toBe(FileAppearanceTypeEnum.custom) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.spec.tsx deleted file mode 100644 index cfbd2a7d56..0000000000 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.spec.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import EmptyFolder from './empty-folder' - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -afterEach(() => { - cleanup() -}) - -describe('EmptyFolder', () => { - it('should render without crashing', () => { - render(<EmptyFolder />) - expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument() - }) - - it('should render the empty folder text', () => { - render(<EmptyFolder />) - expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument() - }) - - it('should have proper styling classes', () => { - const { container } = render(<EmptyFolder />) - const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('flex') - expect(wrapper).toHaveClass('items-center') - expect(wrapper).toHaveClass('justify-center') - }) - - it('should be wrapped with React.memo', () => { - expect((EmptyFolder as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) - }) -}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts new file mode 100644 index 0000000000..231cdcdfc2 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts @@ -0,0 +1,96 @@ +import type { FileItem } from '@/models/datasets' +import { render, renderHook } from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it } from 'vitest' +import { createDataSourceStore, useDataSourceStore, useDataSourceStoreWithSelector } from '../' +import DataSourceProvider from '../provider' + +describe('createDataSourceStore', () => { + it('should create a store with all slices combined', () => { + const store = createDataSourceStore() + const state = store.getState() + + // Common slice + expect(state.currentCredentialId).toBe('') + expect(typeof state.setCurrentCredentialId).toBe('function') + + // LocalFile slice + expect(state.localFileList).toEqual([]) + expect(typeof state.setLocalFileList).toBe('function') + + // OnlineDocument slice + expect(state.documentsData).toEqual([]) + expect(typeof state.setDocumentsData).toBe('function') + + // WebsiteCrawl slice + expect(state.websitePages).toEqual([]) + expect(typeof state.setWebsitePages).toBe('function') + + // OnlineDrive slice + expect(state.breadcrumbs).toEqual([]) + expect(typeof state.setBreadcrumbs).toBe('function') + }) + + it('should allow cross-slice state updates', () => { + const store = createDataSourceStore() + + store.getState().setCurrentCredentialId('cred-1') + store.getState().setLocalFileList([{ file: { id: 'f1' } }] as unknown as FileItem[]) + + expect(store.getState().currentCredentialId).toBe('cred-1') + expect(store.getState().localFileList).toHaveLength(1) + }) + + it('should create independent store instances', () => { + const store1 = createDataSourceStore() + const store2 = createDataSourceStore() + + store1.getState().setCurrentCredentialId('cred-1') + expect(store2.getState().currentCredentialId).toBe('') + }) +}) + +describe('useDataSourceStoreWithSelector', () => { + it('should throw when used outside provider', () => { + expect(() => { + renderHook(() => useDataSourceStoreWithSelector(s => s.currentCredentialId)) + }).toThrow('Missing DataSourceContext.Provider in the tree') + }) + + it('should return selected state when used inside provider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(DataSourceProvider, null, children) + const { result } = renderHook( + () => useDataSourceStoreWithSelector(s => s.currentCredentialId), + { wrapper }, + ) + expect(result.current).toBe('') + }) +}) + +describe('useDataSourceStore', () => { + it('should throw when used outside provider', () => { + expect(() => { + renderHook(() => useDataSourceStore()) + }).toThrow('Missing DataSourceContext.Provider in the tree') + }) + + it('should return store when used inside provider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(DataSourceProvider, null, children) + const { result } = renderHook( + () => useDataSourceStore(), + { wrapper }, + ) + expect(result.current).toBeDefined() + expect(typeof result.current.getState).toBe('function') + }) +}) + +describe('DataSourceProvider', () => { + it('should render children', () => { + const child = React.createElement('div', null, 'Child Content') + const { getByText } = render(React.createElement(DataSourceProvider, null, child)) + expect(getByText('Child Content')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/provider.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/provider.spec.tsx new file mode 100644 index 0000000000..7796c83e17 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/provider.spec.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react' +import { useContext } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DataSourceProvider, { DataSourceContext } from '../provider' + +const mockStore = { getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn() } + +vi.mock('../', () => ({ + createDataSourceStore: () => mockStore, +})) + +// Test consumer component that reads from context +function ContextConsumer() { + const store = useContext(DataSourceContext) + return ( + <div data-testid="context-value" data-has-store={store !== null}> + {store ? 'has-store' : 'no-store'} + </div> + ) +} + +describe('DataSourceProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies children are passed through + describe('Rendering', () => { + it('should render children', () => { + render( + <DataSourceProvider> + <span data-testid="child">Hello</span> + </DataSourceProvider>, + ) + + expect(screen.getByTestId('child')).toBeInTheDocument() + expect(screen.getByText('Hello')).toBeInTheDocument() + }) + }) + + // Context: verifies the store is provided to consumers + describe('Context', () => { + it('should provide store value to context consumers', () => { + render( + <DataSourceProvider> + <ContextConsumer /> + </DataSourceProvider>, + ) + + expect(screen.getByTestId('context-value')).toHaveTextContent('has-store') + expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'true') + }) + + it('should provide null when no provider wraps the consumer', () => { + render(<ContextConsumer />) + + expect(screen.getByTestId('context-value')).toHaveTextContent('no-store') + expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'false') + }) + }) + + // Stability: verifies the store reference is stable across re-renders + describe('Store Stability', () => { + it('should reuse same store on re-render (stable reference)', () => { + const storeValues: Array<typeof mockStore | null> = [] + + function StoreCapture() { + const store = useContext(DataSourceContext) + storeValues.push(store as typeof mockStore | null) + return null + } + + const { rerender } = render( + <DataSourceProvider> + <StoreCapture /> + </DataSourceProvider>, + ) + + rerender( + <DataSourceProvider> + <StoreCapture /> + </DataSourceProvider>, + ) + + expect(storeValues).toHaveLength(2) + expect(storeValues[0]).toBe(storeValues[1]) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/common.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/common.spec.ts new file mode 100644 index 0000000000..b18b7925f2 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/common.spec.ts @@ -0,0 +1,29 @@ +import type { CommonShape } from '../common' +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand' +import { createCommonSlice } from '../common' + +const createTestStore = () => createStore<CommonShape>((...args) => createCommonSlice(...args)) + +describe('createCommonSlice', () => { + it('should initialize with default values', () => { + const state = createTestStore().getState() + + expect(state.currentCredentialId).toBe('') + expect(state.currentNodeIdRef.current).toBe('') + expect(state.currentCredentialIdRef.current).toBe('') + }) + + it('should update currentCredentialId', () => { + const store = createTestStore() + store.getState().setCurrentCredentialId('cred-123') + expect(store.getState().currentCredentialId).toBe('cred-123') + }) + + it('should update currentCredentialId multiple times', () => { + const store = createTestStore() + store.getState().setCurrentCredentialId('cred-1') + store.getState().setCurrentCredentialId('cred-2') + expect(store.getState().currentCredentialId).toBe('cred-2') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts new file mode 100644 index 0000000000..f3ae03acde --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts @@ -0,0 +1,49 @@ +import type { LocalFileSliceShape } from '../local-file' +import type { CustomFile as File, FileItem } from '@/models/datasets' +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand' +import { createLocalFileSlice } from '../local-file' + +const createTestStore = () => createStore<LocalFileSliceShape>((...args) => createLocalFileSlice(...args)) + +describe('createLocalFileSlice', () => { + it('should initialize with default values', () => { + const state = createTestStore().getState() + + expect(state.localFileList).toEqual([]) + expect(state.currentLocalFile).toBeUndefined() + expect(state.previewLocalFileRef.current).toBeUndefined() + }) + + it('should set local file list and update preview ref to first file', () => { + const store = createTestStore() + const files = [ + { file: { id: 'f1', name: 'a.pdf' } }, + { file: { id: 'f2', name: 'b.pdf' } }, + ] as unknown as FileItem[] + + store.getState().setLocalFileList(files) + expect(store.getState().localFileList).toEqual(files) + expect(store.getState().previewLocalFileRef.current).toEqual({ id: 'f1', name: 'a.pdf' }) + }) + + it('should set preview ref to undefined for empty file list', () => { + const store = createTestStore() + store.getState().setLocalFileList([]) + expect(store.getState().previewLocalFileRef.current).toBeUndefined() + }) + + it('should set current local file', () => { + const store = createTestStore() + const file = { id: 'f1', name: 'test.pdf' } as unknown as File + store.getState().setCurrentLocalFile(file) + expect(store.getState().currentLocalFile).toEqual(file) + }) + + it('should clear current local file with undefined', () => { + const store = createTestStore() + store.getState().setCurrentLocalFile({ id: 'f1' } as unknown as File) + store.getState().setCurrentLocalFile(undefined) + expect(store.getState().currentLocalFile).toBeUndefined() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts new file mode 100644 index 0000000000..a98f56c19c --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts @@ -0,0 +1,55 @@ +import type { OnlineDocumentSliceShape } from '../online-document' +import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common' +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand' +import { createOnlineDocumentSlice } from '../online-document' + +const createTestStore = () => createStore<OnlineDocumentSliceShape>((...args) => createOnlineDocumentSlice(...args)) + +describe('createOnlineDocumentSlice', () => { + it('should initialize with default values', () => { + const state = createTestStore().getState() + + expect(state.documentsData).toEqual([]) + expect(state.searchValue).toBe('') + expect(state.onlineDocuments).toEqual([]) + expect(state.currentDocument).toBeUndefined() + expect(state.selectedPagesId).toEqual(new Set()) + expect(state.previewOnlineDocumentRef.current).toBeUndefined() + }) + + it('should set documents data', () => { + const store = createTestStore() + const data = [{ workspace_id: 'w1', pages: [] }] as unknown as DataSourceNotionWorkspace[] + store.getState().setDocumentsData(data) + expect(store.getState().documentsData).toEqual(data) + }) + + it('should set search value', () => { + const store = createTestStore() + store.getState().setSearchValue('hello') + expect(store.getState().searchValue).toBe('hello') + }) + + it('should set online documents and update preview ref', () => { + const store = createTestStore() + const pages = [{ page_id: 'p1' }, { page_id: 'p2' }] as unknown as NotionPage[] + store.getState().setOnlineDocuments(pages) + expect(store.getState().onlineDocuments).toEqual(pages) + expect(store.getState().previewOnlineDocumentRef.current).toEqual({ page_id: 'p1' }) + }) + + it('should set current document', () => { + const store = createTestStore() + const doc = { page_id: 'p1' } as unknown as NotionPage + store.getState().setCurrentDocument(doc) + expect(store.getState().currentDocument).toEqual(doc) + }) + + it('should set selected pages id', () => { + const store = createTestStore() + const ids = new Set(['p1', 'p2']) + store.getState().setSelectedPagesId(ids) + expect(store.getState().selectedPagesId).toEqual(ids) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts new file mode 100644 index 0000000000..f0b61a62a2 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts @@ -0,0 +1,79 @@ +import type { OnlineDriveSliceShape } from '../online-drive' +import type { OnlineDriveFile } from '@/models/pipeline' +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand' +import { createOnlineDriveSlice } from '../online-drive' + +const createTestStore = () => createStore<OnlineDriveSliceShape>((...args) => createOnlineDriveSlice(...args)) + +describe('createOnlineDriveSlice', () => { + it('should initialize with default values', () => { + const state = createTestStore().getState() + + expect(state.breadcrumbs).toEqual([]) + expect(state.prefix).toEqual([]) + expect(state.keywords).toBe('') + expect(state.selectedFileIds).toEqual([]) + expect(state.onlineDriveFileList).toEqual([]) + expect(state.bucket).toBe('') + expect(state.nextPageParameters).toEqual({}) + expect(state.isTruncated.current).toBe(false) + expect(state.previewOnlineDriveFileRef.current).toBeUndefined() + expect(state.hasBucket).toBe(false) + }) + + it('should set breadcrumbs', () => { + const store = createTestStore() + store.getState().setBreadcrumbs(['root', 'folder']) + expect(store.getState().breadcrumbs).toEqual(['root', 'folder']) + }) + + it('should set prefix', () => { + const store = createTestStore() + store.getState().setPrefix(['a', 'b']) + expect(store.getState().prefix).toEqual(['a', 'b']) + }) + + it('should set keywords', () => { + const store = createTestStore() + store.getState().setKeywords('search term') + expect(store.getState().keywords).toBe('search term') + }) + + it('should set selected file ids and update preview ref', () => { + const store = createTestStore() + const files = [ + { id: 'file-1', name: 'a.pdf', type: 'file' }, + { id: 'file-2', name: 'b.pdf', type: 'file' }, + ] as unknown as OnlineDriveFile[] + store.getState().setOnlineDriveFileList(files) + store.getState().setSelectedFileIds(['file-1']) + + expect(store.getState().selectedFileIds).toEqual(['file-1']) + expect(store.getState().previewOnlineDriveFileRef.current).toEqual(files[0]) + }) + + it('should set preview ref to undefined when selected id not found', () => { + const store = createTestStore() + store.getState().setSelectedFileIds(['non-existent']) + expect(store.getState().previewOnlineDriveFileRef.current).toBeUndefined() + }) + + it('should set bucket', () => { + const store = createTestStore() + store.getState().setBucket('my-bucket') + expect(store.getState().bucket).toBe('my-bucket') + }) + + it('should set next page parameters', () => { + const store = createTestStore() + store.getState().setNextPageParameters({ cursor: 'abc' }) + expect(store.getState().nextPageParameters).toEqual({ cursor: 'abc' }) + }) + + it('should set hasBucket', () => { + const store = createTestStore() + store.getState().setHasBucket(true) + expect(store.getState().hasBucket).toBe(true) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts new file mode 100644 index 0000000000..a81ef61c03 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts @@ -0,0 +1,65 @@ +import type { WebsiteCrawlSliceShape } from '../website-crawl' +import type { CrawlResult, CrawlResultItem } from '@/models/datasets' +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand' +import { CrawlStep } from '@/models/datasets' +import { createWebsiteCrawlSlice } from '../website-crawl' + +const createTestStore = () => createStore<WebsiteCrawlSliceShape>((...args) => createWebsiteCrawlSlice(...args)) + +describe('createWebsiteCrawlSlice', () => { + it('should initialize with default values', () => { + const state = createTestStore().getState() + + expect(state.websitePages).toEqual([]) + expect(state.currentWebsite).toBeUndefined() + expect(state.crawlResult).toBeUndefined() + expect(state.step).toBe(CrawlStep.init) + expect(state.previewIndex).toBe(-1) + expect(state.previewWebsitePageRef.current).toBeUndefined() + }) + + it('should set website pages and update preview ref', () => { + const store = createTestStore() + const pages = [ + { title: 'Page 1', source_url: 'https://a.com' }, + { title: 'Page 2', source_url: 'https://b.com' }, + ] as unknown as CrawlResultItem[] + store.getState().setWebsitePages(pages) + expect(store.getState().websitePages).toEqual(pages) + expect(store.getState().previewWebsitePageRef.current).toEqual(pages[0]) + }) + + it('should set current website', () => { + const store = createTestStore() + const website = { title: 'Page 1' } as unknown as CrawlResultItem + store.getState().setCurrentWebsite(website) + expect(store.getState().currentWebsite).toEqual(website) + }) + + it('should set crawl result', () => { + const store = createTestStore() + const result = { data: { count: 5 } } as unknown as CrawlResult + store.getState().setCrawlResult(result) + expect(store.getState().crawlResult).toEqual(result) + }) + + it('should set step', () => { + const store = createTestStore() + store.getState().setStep(CrawlStep.running) + expect(store.getState().step).toBe(CrawlStep.running) + }) + + it('should set preview index', () => { + const store = createTestStore() + store.getState().setPreviewIndex(3) + expect(store.getState().previewIndex).toBe(3) + }) + + it('should clear current website with undefined', () => { + const store = createTestStore() + store.getState().setCurrentWebsite({ title: 'X' } as unknown as CrawlResultItem) + store.getState().setCurrentWebsite(undefined) + expect(store.getState().currentWebsite).toBeUndefined() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx index 493dd25730..576edbaf96 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx @@ -4,13 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { CrawlStep } from '@/models/datasets' -import WebsiteCrawl from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import WebsiteCrawl from '../index' // Mock useDocLink - context hook requires mocking const mockDocLink = vi.fn((path?: string) => `https://docs.example.com${path || ''}`) @@ -21,13 +15,13 @@ vi.mock('@/context/i18n', () => ({ // Mock dataset-detail context - context provider requires mocking let mockPipelineId: string | undefined = 'pipeline-123' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (s: any) => any) => selector({ dataset: { pipeline_id: mockPipelineId } }), + useDatasetDetailContextWithSelector: (selector: (s: { dataset: { pipeline_id: string | undefined } }) => unknown) => selector({ dataset: { pipeline_id: mockPipelineId } }), })) // Mock modal context - context provider requires mocking const mockSetShowAccountSettingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: (selector: (s: any) => any) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), + useModalContextSelector: (selector: (s: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), })) // Mock ssePost - API service requires mocking @@ -61,7 +55,6 @@ vi.mock('@/service/use-pipeline', () => ({ // Note: zustand/react/shallow useShallow is imported directly (simple utility function) -// Mock store const mockStoreState = { crawlResult: undefined as { data: CrawlResultItem[], time_consuming: number | string } | undefined, step: CrawlStep.init, @@ -78,39 +71,39 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../store', () => ({ - useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState), +vi.mock('../../store', () => ({ + useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), useDataSourceStore: () => mockDataSourceStore, })) // Mock Header component -vi.mock('../base/header', () => ({ - default: (props: any) => ( +vi.mock('../../base/header', () => ({ + default: (props: Record<string, unknown>) => ( <div data-testid="header"> - <span data-testid="header-doc-title">{props.docTitle}</span> - <span data-testid="header-doc-link">{props.docLink}</span> - <span data-testid="header-plugin-name">{props.pluginName}</span> - <span data-testid="header-credential-id">{props.currentCredentialId}</span> - <button data-testid="header-config-btn" onClick={props.onClickConfiguration}>Configure</button> - <button data-testid="header-credential-change" onClick={() => props.onCredentialChange('new-cred-id')}>Change Credential</button> - <span data-testid="header-credentials-count">{props.credentials?.length || 0}</span> + <span data-testid="header-doc-title">{props.docTitle as string}</span> + <span data-testid="header-doc-link">{props.docLink as string}</span> + <span data-testid="header-plugin-name">{props.pluginName as string}</span> + <span data-testid="header-credential-id">{props.currentCredentialId as string}</span> + <button data-testid="header-config-btn" onClick={props.onClickConfiguration as () => void}>Configure</button> + <button data-testid="header-credential-change" onClick={() => (props.onCredentialChange as (id: string) => void)('new-cred-id')}>Change Credential</button> + <span data-testid="header-credentials-count">{(props.credentials as unknown[] | undefined)?.length || 0}</span> </div> ), })) // Mock Options component const mockOptionsSubmit = vi.fn() -vi.mock('./base/options', () => ({ - default: (props: any) => ( +vi.mock('../base/options', () => ({ + default: (props: Record<string, unknown>) => ( <div data-testid="options"> - <span data-testid="options-step">{props.step}</span> + <span data-testid="options-step">{props.step as string}</span> <span data-testid="options-run-disabled">{String(props.runDisabled)}</span> - <span data-testid="options-variables-count">{props.variables?.length || 0}</span> + <span data-testid="options-variables-count">{(props.variables as unknown[] | undefined)?.length || 0}</span> <button data-testid="options-submit-btn" onClick={() => { mockOptionsSubmit() - props.onSubmit({ url: 'https://example.com', depth: 2 }) + ;(props.onSubmit as (v: Record<string, unknown>) => void)({ url: 'https://example.com', depth: 2 }) }} > Submit @@ -120,44 +113,44 @@ vi.mock('./base/options', () => ({ })) // Mock Crawling component -vi.mock('./base/crawling', () => ({ - default: (props: any) => ( +vi.mock('../base/crawling', () => ({ + default: (props: Record<string, unknown>) => ( <div data-testid="crawling"> - <span data-testid="crawling-crawled-num">{props.crawledNum}</span> - <span data-testid="crawling-total-num">{props.totalNum}</span> + <span data-testid="crawling-crawled-num">{props.crawledNum as number}</span> + <span data-testid="crawling-total-num">{props.totalNum as number}</span> </div> ), })) // Mock ErrorMessage component -vi.mock('./base/error-message', () => ({ - default: (props: any) => ( - <div data-testid="error-message" className={props.className}> - <span data-testid="error-title">{props.title}</span> - <span data-testid="error-msg">{props.errorMsg}</span> +vi.mock('../base/error-message', () => ({ + default: (props: Record<string, unknown>) => ( + <div data-testid="error-message" className={props.className as string}> + <span data-testid="error-title">{props.title as string}</span> + <span data-testid="error-msg">{props.errorMsg as string}</span> </div> ), })) // Mock CrawledResult component -vi.mock('./base/crawled-result', () => ({ - default: (props: any) => ( - <div data-testid="crawled-result" className={props.className}> - <span data-testid="crawled-result-count">{props.list?.length || 0}</span> - <span data-testid="crawled-result-checked-count">{props.checkedList?.length || 0}</span> - <span data-testid="crawled-result-used-time">{props.usedTime}</span> - <span data-testid="crawled-result-preview-index">{props.previewIndex}</span> +vi.mock('../base/crawled-result', () => ({ + default: (props: Record<string, unknown>) => ( + <div data-testid="crawled-result" className={props.className as string}> + <span data-testid="crawled-result-count">{(props.list as unknown[] | undefined)?.length || 0}</span> + <span data-testid="crawled-result-checked-count">{(props.checkedList as unknown[] | undefined)?.length || 0}</span> + <span data-testid="crawled-result-used-time">{props.usedTime as number}</span> + <span data-testid="crawled-result-preview-index">{props.previewIndex as number}</span> <span data-testid="crawled-result-show-preview">{String(props.showPreview)}</span> <span data-testid="crawled-result-multiple-choice">{String(props.isMultipleChoice)}</span> <button data-testid="crawled-result-select-change" - onClick={() => props.onSelectedChange([{ source_url: 'https://example.com', title: 'Test' }])} + onClick={() => (props.onSelectedChange as (v: { source_url: string, title: string }[]) => void)([{ source_url: 'https://example.com', title: 'Test' }])} > Change Selection </button> <button data-testid="crawled-result-preview" - onClick={() => props.onPreview?.({ source_url: 'https://example.com', title: 'Test' }, 0)} + onClick={() => (props.onPreview as ((item: { source_url: string, title: string }, idx: number) => void) | undefined)?.({ source_url: 'https://example.com', title: 'Test' }, 0)} > Preview </button> @@ -165,9 +158,6 @@ vi.mock('./base/crawled-result', () => ({ ), })) -// ========================================== -// Test Data Builders -// ========================================== const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ title: 'Test Node', plugin_id: 'plugin-123', @@ -209,9 +199,6 @@ const createDefaultProps = (overrides?: Partial<WebsiteCrawlProps>): WebsiteCraw ...overrides, }) -// ========================================== -// Test Suites -// ========================================== describe('WebsiteCrawl', () => { beforeEach(() => { vi.clearAllMocks() @@ -250,81 +237,62 @@ describe('WebsiteCrawl', () => { mockGetState.mockReturnValue(mockStoreState) }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header')).toBeInTheDocument() expect(screen.getByTestId('options')).toBeInTheDocument() }) it('should render Header with correct props', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-123' const props = createDefaultProps({ nodeData: createMockNodeData({ datasource_label: 'My Website Crawler' }), }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-doc-title')).toHaveTextContent('Docs') expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('My Website Crawler') expect(screen.getByTestId('header-credential-id')).toHaveTextContent('cred-123') }) it('should render Options with correct props', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('options')).toBeInTheDocument() expect(screen.getByTestId('options-step')).toHaveTextContent(CrawlStep.init) }) it('should not render Crawling or CrawledResult when step is init', () => { - // Arrange mockStoreState.step = CrawlStep.init const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.queryByTestId('crawling')).not.toBeInTheDocument() expect(screen.queryByTestId('crawled-result')).not.toBeInTheDocument() expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() }) it('should render Crawling when step is running', () => { - // Arrange mockStoreState.step = CrawlStep.running const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawling')).toBeInTheDocument() expect(screen.queryByTestId('crawled-result')).not.toBeInTheDocument() expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() }) it('should render CrawledResult when step is finished with no error', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -332,30 +300,23 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result')).toBeInTheDocument() expect(screen.queryByTestId('crawling')).not.toBeInTheDocument() expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('nodeId prop', () => { it('should use nodeId in datasourceNodeRunURL for non-pipeline mode', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ nodeId: 'custom-node-id', isInPipeline: false, }) - // Act render(<WebsiteCrawl {...props} />) // Assert - Options uses nodeId through usePreProcessingParams @@ -368,17 +329,14 @@ describe('WebsiteCrawl', () => { describe('nodeData prop', () => { it('should pass plugin_id and provider_name to useGetDataSourceAuth', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'my-plugin-id', provider_name: 'my-provider', }) const props = createDefaultProps({ nodeData }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId: 'my-plugin-id', provider: 'my-provider', @@ -386,47 +344,37 @@ describe('WebsiteCrawl', () => { }) it('should pass datasource_label to Header as pluginName', () => { - // Arrange const nodeData = createMockNodeData({ datasource_label: 'Custom Website Scraper', }) const props = createDefaultProps({ nodeData }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('Custom Website Scraper') }) }) describe('isInPipeline prop', () => { it('should use draft URL when isInPipeline is true', () => { - // Arrange const props = createDefaultProps({ isInPipeline: true }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalled() expect(mockUsePublishedPipelinePreProcessingParams).not.toHaveBeenCalled() }) it('should use published URL when isInPipeline is false', () => { - // Arrange const props = createDefaultProps({ isInPipeline: false }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalled() expect(mockUseDraftPipelinePreProcessingParams).not.toHaveBeenCalled() }) it('should pass showPreview as false to CrawledResult when isInPipeline is true', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -434,15 +382,12 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps({ isInPipeline: true }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent('false') }) it('should pass showPreview as true to CrawledResult when isInPipeline is false', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -450,17 +395,14 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps({ isInPipeline: false }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent('true') }) }) describe('supportBatchUpload prop', () => { it('should pass isMultipleChoice as true to CrawledResult when supportBatchUpload is true', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -468,15 +410,12 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps({ supportBatchUpload: true }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent('true') }) it('should pass isMultipleChoice as false to CrawledResult when supportBatchUpload is false', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -484,10 +423,8 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps({ supportBatchUpload: false }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent('false') }) @@ -496,7 +433,6 @@ describe('WebsiteCrawl', () => { [false, 'false'], [undefined, 'true'], // Default value ])('should handle supportBatchUpload=%s correctly', (value, expected) => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -504,40 +440,30 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps({ supportBatchUpload: value }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent(expected) }) }) describe('onCredentialChange prop', () => { it('should call onCredentialChange with credential id and reset state', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) - // Act render(<WebsiteCrawl {...props} />) fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) }) - // ========================================== - // State Management Tests - // ========================================== describe('State Management', () => { it('should display correct crawledNum and totalNum when running', () => { - // Arrange mockStoreState.step = CrawlStep.running const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) // Assert - Initial state is 0/0 @@ -546,7 +472,6 @@ describe('WebsiteCrawl', () => { }) it('should update step and result via ssePost callbacks', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockCrawlData: CrawlResultItem[] = [ createMockCrawlResultItem({ source_url: 'https://example.com/1' }), @@ -572,7 +497,6 @@ describe('WebsiteCrawl', () => { // Act - Trigger submit fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running) expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({ @@ -584,19 +508,15 @@ describe('WebsiteCrawl', () => { }) it('should pass runDisabled as true when no credential is selected', () => { - // Arrange mockStoreState.currentCredentialId = '' const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('true') }) it('should pass runDisabled as true when params are being fetched', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockUsePublishedPipelinePreProcessingParams.mockReturnValue({ data: { variables: [] }, @@ -604,15 +524,12 @@ describe('WebsiteCrawl', () => { }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('true') }) it('should pass runDisabled as false when credential is selected and params are loaded', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockUsePublishedPipelinePreProcessingParams.mockReturnValue({ data: { variables: [] }, @@ -620,20 +537,15 @@ describe('WebsiteCrawl', () => { }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('false') }) }) - // ========================================== // Callback Stability and Memoization - // ========================================== describe('Callback Stability and Memoization', () => { it('should have stable handleCheckedCrawlResultChange that updates store', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -642,17 +554,14 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('crawled-result-select-change')) - // Assert expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith([ { source_url: 'https://example.com', title: 'Test' }, ]) }) it('should have stable handlePreview that updates store', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -661,10 +570,8 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('crawled-result-preview')) - // Assert expect(mockStoreState.setCurrentWebsite).toHaveBeenCalledWith({ source_url: 'https://example.com', title: 'Test', @@ -673,47 +580,36 @@ describe('WebsiteCrawl', () => { }) it('should have stable handleSetting callback', () => { - // Arrange const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }) it('should have stable handleCredentialChange that resets state', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions and Event Handlers', () => { it('should handle submit and trigger ssePost', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running) @@ -721,34 +617,27 @@ describe('WebsiteCrawl', () => { }) it('should handle configuration button click', () => { - // Arrange const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }) it('should handle credential change', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) it('should handle selection change in CrawledResult', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -757,15 +646,12 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('crawled-result-select-change')) - // Assert expect(mockStoreState.setWebsitePages).toHaveBeenCalled() }) it('should handle preview in CrawledResult', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -774,21 +660,16 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('crawled-result-preview')) - // Assert expect(mockStoreState.setCurrentWebsite).toHaveBeenCalled() expect(mockStoreState.setPreviewIndex).toHaveBeenCalled() }) }) - // ========================================== // API Calls Mocking - // ========================================== describe('API Calls', () => { it('should call ssePost with correct parameters for published workflow', async () => { - // Arrange mockStoreState.currentCredentialId = 'test-cred' mockPipelineId = 'pipeline-456' const props = createDefaultProps({ @@ -797,10 +678,8 @@ describe('WebsiteCrawl', () => { }) render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( '/rag/pipelines/pipeline-456/workflows/published/datasource/nodes/node-789/run', @@ -818,7 +697,6 @@ describe('WebsiteCrawl', () => { }) it('should call ssePost with correct parameters for draft workflow', async () => { - // Arrange mockStoreState.currentCredentialId = 'test-cred' mockPipelineId = 'pipeline-456' const props = createDefaultProps({ @@ -827,10 +705,8 @@ describe('WebsiteCrawl', () => { }) render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( '/rag/pipelines/pipeline-456/workflows/draft/datasource/nodes/node-789/run', @@ -841,7 +717,6 @@ describe('WebsiteCrawl', () => { }) it('should handle onDataSourceNodeProcessing callback correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.step = CrawlStep.running @@ -855,21 +730,18 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() const { rerender } = render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) // Update store state to simulate running step mockStoreState.step = CrawlStep.running rerender(<WebsiteCrawl {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should handle onDataSourceNodeCompleted callback correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockCrawlData: CrawlResultItem[] = [ createMockCrawlResultItem({ source_url: 'https://example.com/1' }), @@ -886,10 +758,8 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({ data: mockCrawlData, @@ -901,7 +771,6 @@ describe('WebsiteCrawl', () => { }) it('should handle onDataSourceNodeCompleted with single result when supportBatchUpload is false', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockCrawlData: CrawlResultItem[] = [ createMockCrawlResultItem({ source_url: 'https://example.com/1' }), @@ -919,10 +788,8 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps({ supportBatchUpload: false }) render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { // Should only select first item when supportBatchUpload is false expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith([mockCrawlData[0]]) @@ -930,7 +797,6 @@ describe('WebsiteCrawl', () => { }) it('should handle onDataSourceNodeError callback correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -942,27 +808,22 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) }) }) it('should use useGetDataSourceAuth with correct parameters', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'website-plugin', provider_name: 'website-provider', }) const props = createDefaultProps({ nodeData }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId: 'website-plugin', provider: 'website-provider', @@ -970,7 +831,6 @@ describe('WebsiteCrawl', () => { }) it('should pass credentials from useGetDataSourceAuth to Header', () => { - // Arrange const mockCredentials = [ createMockCredential({ id: 'cred-1', name: 'Credential 1' }), createMockCredential({ id: 'cred-2', name: 'Credential 2' }), @@ -980,62 +840,47 @@ describe('WebsiteCrawl', () => { }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('2') }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty credentials array', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: { result: [] }, }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle undefined dataSourceAuth result', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: { result: undefined }, }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle null dataSourceAuth data', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: null, }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle empty crawlResult data array', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [], @@ -1043,28 +888,22 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-count')).toHaveTextContent('0') }) it('should handle undefined crawlResult', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = undefined const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-count')).toHaveTextContent('0') }) it('should handle time_consuming as string', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -1072,15 +911,12 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-used-time')).toHaveTextContent('2.5') }) it('should handle invalid time_consuming value', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -1088,7 +924,6 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) // Assert - NaN should become 0 @@ -1096,14 +931,11 @@ describe('WebsiteCrawl', () => { }) it('should handle undefined pipelineId gracefully', () => { - // Arrange mockPipelineId = undefined const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalledWith( { pipeline_id: undefined, node_id: 'node-1' }, false, // enabled should be false when pipelineId is undefined @@ -1111,13 +943,10 @@ describe('WebsiteCrawl', () => { }) it('should handle empty nodeId gracefully', () => { - // Arrange const props = createDefaultProps({ nodeId: '' }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalledWith( { pipeline_id: 'pipeline-123', node_id: '' }, false, // enabled should be false when nodeId is empty @@ -1132,7 +961,6 @@ describe('WebsiteCrawl', () => { }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) // Assert - Options should receive empty array as variables @@ -1147,7 +975,6 @@ describe('WebsiteCrawl', () => { }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) // Assert - Options should receive empty array as variables @@ -1155,7 +982,6 @@ describe('WebsiteCrawl', () => { }) it('should handle error without error message', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1167,7 +993,6 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) // Assert - Should use fallback error message @@ -1177,7 +1002,6 @@ describe('WebsiteCrawl', () => { }) it('should handle null total and completed in processing callback', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1190,7 +1014,6 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) // Assert - Should handle null values gracefully (default to 0) @@ -1200,7 +1023,6 @@ describe('WebsiteCrawl', () => { }) it('should handle undefined time_consuming in completed callback', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1213,10 +1035,8 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({ data: [expect.any(Object)], @@ -1226,9 +1046,7 @@ describe('WebsiteCrawl', () => { }) }) - // ========================================== // All Prop Variations - // ========================================== describe('Prop Variations', () => { it.each([ [{ isInPipeline: true, supportBatchUpload: true }], @@ -1236,7 +1054,6 @@ describe('WebsiteCrawl', () => { [{ isInPipeline: false, supportBatchUpload: true }], [{ isInPipeline: false, supportBatchUpload: false }], ])('should render correctly with props %o', (propVariation) => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -1244,10 +1061,8 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps(propVariation) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result')).toBeInTheDocument() expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent( String(!propVariation.isInPipeline), @@ -1258,7 +1073,6 @@ describe('WebsiteCrawl', () => { }) it('should use default values for optional props', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -1271,7 +1085,6 @@ describe('WebsiteCrawl', () => { // isInPipeline and supportBatchUpload are not provided } - // Act render(<WebsiteCrawl {...props} />) // Assert - Default values: isInPipeline = false, supportBatchUpload = true @@ -1280,9 +1093,7 @@ describe('WebsiteCrawl', () => { }) }) - // ========================================== // Error Display - // ========================================== describe('Error Display', () => { it('should show ErrorMessage when crawl finishes with error', async () => { // Arrange - Need to create a scenario where error message is set @@ -1313,7 +1124,6 @@ describe('WebsiteCrawl', () => { }) it('should not show ErrorMessage when crawl finishes without error', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -1321,21 +1131,15 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() expect(screen.getByTestId('crawled-result')).toBeInTheDocument() }) }) - // ========================================== - // Integration Tests - // ========================================== describe('Integration', () => { it('should complete full workflow: submit -> running -> completed', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockCrawlData: CrawlResultItem[] = [ createMockCrawlResultItem({ source_url: 'https://example.com/1' }), @@ -1378,7 +1182,6 @@ describe('WebsiteCrawl', () => { }) it('should handle error flow correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1390,10 +1193,8 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running) expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) @@ -1401,23 +1202,19 @@ describe('WebsiteCrawl', () => { }) it('should handle credential change and allow new crawl', () => { - // Arrange mockStoreState.currentCredentialId = 'initial-cred' const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) - // Act render(<WebsiteCrawl {...props} />) // Change credential fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) it('should handle preview selection after crawl completes', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [ @@ -1432,21 +1229,16 @@ describe('WebsiteCrawl', () => { // Act - Preview first item fireEvent.click(screen.getByTestId('crawled-result-preview')) - // Assert expect(mockStoreState.setCurrentWebsite).toHaveBeenCalled() expect(mockStoreState.setPreviewIndex).toHaveBeenCalledWith(0) }) }) - // ========================================== // Component Memoization - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange const props = createDefaultProps() - // Act const { rerender } = render(<WebsiteCrawl {...props} />) rerender(<WebsiteCrawl {...props} />) @@ -1456,11 +1248,9 @@ describe('WebsiteCrawl', () => { }) it('should not re-run callbacks when props are the same', () => { - // Arrange const onCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange }) - // Act const { rerender } = render(<WebsiteCrawl {...props} />) rerender(<WebsiteCrawl {...props} />) @@ -1470,30 +1260,22 @@ describe('WebsiteCrawl', () => { }) }) - // ========================================== // Styling - // ========================================== describe('Styling', () => { it('should apply correct container classes', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<WebsiteCrawl {...props} />) - // Assert const rootDiv = container.firstChild as HTMLElement expect(rootDiv).toHaveClass('flex', 'flex-col') }) it('should apply correct classes to options container', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<WebsiteCrawl {...props} />) - // Assert const optionsContainer = container.querySelector('.rounded-xl') expect(optionsContainer).toBeInTheDocument() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/checkbox-with-label.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/checkbox-with-label.spec.tsx new file mode 100644 index 0000000000..574d8ba174 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/checkbox-with-label.spec.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CheckboxWithLabel from '../checkbox-with-label' + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => ( + <input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} /> + ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>, +})) + +describe('CheckboxWithLabel', () => { + const defaultProps = { + isChecked: false, + onChange: vi.fn(), + label: 'Test Label', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render label text', () => { + render(<CheckboxWithLabel {...defaultProps} />) + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('should render checkbox', () => { + render(<CheckboxWithLabel {...defaultProps} />) + expect(screen.getByTestId('checkbox')).toBeInTheDocument() + }) + + it('should render tooltip when provided', () => { + render(<CheckboxWithLabel {...defaultProps} tooltip="Help text" />) + expect(screen.getByTestId('tooltip')).toBeInTheDocument() + }) + + it('should not render tooltip when not provided', () => { + render(<CheckboxWithLabel {...defaultProps} />) + expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + }) + + it('should apply custom className', () => { + const { container } = render(<CheckboxWithLabel {...defaultProps} className="custom-cls" />) + expect(container.querySelector('.custom-cls')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx new file mode 100644 index 0000000000..80d1f4ee19 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx @@ -0,0 +1,69 @@ +import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CrawledResultItem from '../crawled-result-item' + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( + <button data-testid="preview-button" onClick={onClick}>{children}</button> + ), +})) + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => ( + <input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} /> + ), +})) + +vi.mock('@/app/components/base/radio/ui', () => ({ + default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => ( + <input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} /> + ), +})) + +describe('CrawledResultItem', () => { + const defaultProps = { + payload: { + title: 'Test Page', + source_url: 'https://example.com/page', + markdown: '', + description: '', + } satisfies CrawlResultItemType, + isChecked: false, + onCheckChange: vi.fn(), + isPreview: false, + showPreview: true, + onPreview: vi.fn(), + isMultipleChoice: true, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render title and URL', () => { + render(<CrawledResultItem {...defaultProps} />) + expect(screen.getByText('Test Page')).toBeInTheDocument() + expect(screen.getByText('https://example.com/page')).toBeInTheDocument() + }) + + it('should render checkbox in multiple choice mode', () => { + render(<CrawledResultItem {...defaultProps} />) + expect(screen.getByTestId('checkbox')).toBeInTheDocument() + }) + + it('should render radio in single choice mode', () => { + render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />) + expect(screen.getByTestId('radio')).toBeInTheDocument() + }) + + it('should show preview button when showPreview is true', () => { + render(<CrawledResultItem {...defaultProps} />) + expect(screen.getByTestId('preview-button')).toBeInTheDocument() + }) + + it('should not show preview button when showPreview is false', () => { + render(<CrawledResultItem {...defaultProps} showPreview={false} />) + expect(screen.queryByTestId('preview-button')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx new file mode 100644 index 0000000000..9c71f91d8d --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx @@ -0,0 +1,214 @@ +import type { CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' + +import CrawledResult from '../crawled-result' + +vi.mock('../checkbox-with-label', () => ({ + default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => ( + <label> + <input + type="checkbox" + checked={isChecked} + onChange={onChange} + data-testid="check-all-checkbox" + /> + {label} + </label> + ), +})) + +vi.mock('../crawled-result-item', () => ({ + default: ({ + payload, + isChecked, + onCheckChange, + onPreview, + }: { + payload: CrawlResultItem + isChecked: boolean + onCheckChange: (checked: boolean) => void + onPreview: () => void + }) => ( + <div data-testid={`crawled-item-${payload.source_url}`}> + <span data-testid="item-url">{payload.source_url}</span> + <button data-testid={`check-${payload.source_url}`} onClick={() => onCheckChange(!isChecked)}> + {isChecked ? 'uncheck' : 'check'} + </button> + <button data-testid={`preview-${payload.source_url}`} onClick={onPreview}> + preview + </button> + </div> + ), +})) + +const createItem = (url: string): CrawlResultItem => ({ + source_url: url, + title: `Title for ${url}`, + markdown: `# ${url}`, + description: `Desc for ${url}`, +}) + +const defaultList: CrawlResultItem[] = [ + createItem('https://example.com/a'), + createItem('https://example.com/b'), + createItem('https://example.com/c'), +] + +describe('CrawledResult', () => { + const defaultProps = { + list: defaultList, + checkedList: [] as CrawlResultItem[], + onSelectedChange: vi.fn(), + usedTime: 12.345, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render scrap time info with correct total and time', () => { + render(<CrawledResult {...defaultProps} />) + + expect( + screen.getByText(/scrapTimeInfo/), + ).toBeInTheDocument() + // The global i18n mock serialises params, so verify total and time appear + expect(screen.getByText(/"total":3/)).toBeInTheDocument() + expect(screen.getByText(/"time":"12.3"/)).toBeInTheDocument() + }) + + it('should render all items from list', () => { + render(<CrawledResult {...defaultProps} />) + + for (const item of defaultList) { + expect(screen.getByTestId(`crawled-item-${item.source_url}`)).toBeInTheDocument() + } + }) + + it('should apply custom className', () => { + const { container } = render( + <CrawledResult {...defaultProps} className="my-custom-class" />, + ) + + expect(container.firstChild).toHaveClass('my-custom-class') + }) + }) + + // Check-all checkbox visibility + describe('Check All Checkbox', () => { + it('should show check-all checkbox in multiple choice mode', () => { + render(<CrawledResult {...defaultProps} isMultipleChoice={true} />) + + expect(screen.getByTestId('check-all-checkbox')).toBeInTheDocument() + }) + + it('should hide check-all checkbox in single choice mode', () => { + render(<CrawledResult {...defaultProps} isMultipleChoice={false} />) + + expect(screen.queryByTestId('check-all-checkbox')).not.toBeInTheDocument() + }) + }) + + // Toggle all items + describe('Toggle All', () => { + it('should select all when not all checked', () => { + const onSelectedChange = vi.fn() + render( + <CrawledResult + {...defaultProps} + checkedList={[defaultList[0]]} + onSelectedChange={onSelectedChange} + />, + ) + + fireEvent.click(screen.getByTestId('check-all-checkbox')) + + expect(onSelectedChange).toHaveBeenCalledWith(defaultList) + }) + + it('should deselect all when all checked', () => { + const onSelectedChange = vi.fn() + render( + <CrawledResult + {...defaultProps} + checkedList={[...defaultList]} + onSelectedChange={onSelectedChange} + />, + ) + + fireEvent.click(screen.getByTestId('check-all-checkbox')) + + expect(onSelectedChange).toHaveBeenCalledWith([]) + }) + }) + + // Individual item check + describe('Individual Item Check', () => { + it('should add item to selection in multiple choice mode', () => { + const onSelectedChange = vi.fn() + render( + <CrawledResult + {...defaultProps} + checkedList={[defaultList[0]]} + onSelectedChange={onSelectedChange} + isMultipleChoice={true} + />, + ) + + fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`)) + + expect(onSelectedChange).toHaveBeenCalledWith([defaultList[0], defaultList[1]]) + }) + + it('should replace selection in single choice mode', () => { + const onSelectedChange = vi.fn() + render( + <CrawledResult + {...defaultProps} + checkedList={[defaultList[0]]} + onSelectedChange={onSelectedChange} + isMultipleChoice={false} + />, + ) + + fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`)) + + expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]]) + }) + + it('should remove item from selection when unchecked', () => { + const onSelectedChange = vi.fn() + render( + <CrawledResult + {...defaultProps} + checkedList={[defaultList[0], defaultList[1]]} + onSelectedChange={onSelectedChange} + isMultipleChoice={true} + />, + ) + + fireEvent.click(screen.getByTestId(`check-${defaultList[0].source_url}`)) + + expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]]) + }) + }) + + // Preview + describe('Preview', () => { + it('should call onPreview with correct item and index', () => { + const onPreview = vi.fn() + render( + <CrawledResult + {...defaultProps} + onPreview={onPreview} + showPreview={true} + />, + ) + + fireEvent.click(screen.getByTestId(`preview-${defaultList[1].source_url}`)) + + expect(onPreview).toHaveBeenCalledWith(defaultList[1], 1) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawling.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawling.spec.tsx new file mode 100644 index 0000000000..e2836b7978 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawling.spec.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Crawling from '../crawling' + +describe('Crawling', () => { + it('should render crawl progress', () => { + render(<Crawling crawledNum={5} totalNum={10} />) + expect(screen.getByText(/5/)).toBeInTheDocument() + expect(screen.getByText(/10/)).toBeInTheDocument() + }) + + it('should render total page scraped label', () => { + render(<Crawling crawledNum={0} totalNum={0} />) + expect(screen.getByText(/stepOne\.website\.totalPageScraped/)).toBeInTheDocument() + }) + + it('should apply custom className', () => { + const { container } = render(<Crawling crawledNum={1} totalNum={5} className="custom" />) + expect(container.querySelector('.custom')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/error-message.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/error-message.spec.tsx new file mode 100644 index 0000000000..ee989c6224 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/error-message.spec.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import ErrorMessage from '../error-message' + +describe('ErrorMessage', () => { + it('should render title', () => { + render(<ErrorMessage title="Something went wrong" />) + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('should render error message when provided', () => { + render(<ErrorMessage title="Error" errorMsg="Detailed error info" />) + expect(screen.getByText('Detailed error info')).toBeInTheDocument() + }) + + it('should not render error message when not provided', () => { + const { container } = render(<ErrorMessage title="Error" />) + const textElements = container.querySelectorAll('.system-xs-regular') + expect(textElements).toHaveLength(0) + }) + + it('should apply custom className', () => { + const { container } = render(<ErrorMessage title="Error" className="custom-cls" />) + expect(container.querySelector('.custom-cls')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx index 94de64d791..f537d63a73 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx @@ -1,15 +1,11 @@ import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import CheckboxWithLabel from './checkbox-with-label' -import CrawledResult from './crawled-result' -import CrawledResultItem from './crawled-result-item' -import Crawling from './crawling' -import ErrorMessage from './error-message' - -// ========================================== -// Test Data Builders -// ========================================== +import CheckboxWithLabel from '../checkbox-with-label' +import CrawledResult from '../crawled-result' +import CrawledResultItem from '../crawled-result-item' +import Crawling from '../crawling' +import ErrorMessage from '../error-message' const createMockCrawlResultItem = (overrides?: Partial<CrawlResultItemType>): CrawlResultItemType => ({ source_url: 'https://example.com/page1', @@ -27,9 +23,7 @@ const createMockCrawlResultItems = (count = 3): CrawlResultItemType[] => { })) } -// ========================================== // CheckboxWithLabel Tests -// ========================================== describe('CheckboxWithLabel', () => { const defaultProps = { isChecked: false, @@ -43,15 +37,12 @@ describe('CheckboxWithLabel', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<CheckboxWithLabel {...defaultProps} />) - // Assert expect(screen.getByText('Test Label')).toBeInTheDocument() }) it('should render checkbox in unchecked state', () => { - // Arrange & Act const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={false} />) // Assert - Custom checkbox component uses div with data-testid @@ -61,7 +52,6 @@ describe('CheckboxWithLabel', () => { }) it('should render checkbox in checked state', () => { - // Arrange & Act const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={true} />) // Assert - Checked state has check icon @@ -70,7 +60,6 @@ describe('CheckboxWithLabel', () => { }) it('should render tooltip when provided', () => { - // Arrange & Act render(<CheckboxWithLabel {...defaultProps} tooltip="Helpful tooltip text" />) // Assert - Tooltip trigger should be present @@ -79,10 +68,8 @@ describe('CheckboxWithLabel', () => { }) it('should not render tooltip when not provided', () => { - // Arrange & Act render(<CheckboxWithLabel {...defaultProps} />) - // Assert const tooltipTrigger = document.querySelector('[class*="ml-0.5"]') expect(tooltipTrigger).not.toBeInTheDocument() }) @@ -90,21 +77,17 @@ describe('CheckboxWithLabel', () => { describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( <CheckboxWithLabel {...defaultProps} className="custom-class" />, ) - // Assert const label = container.querySelector('label') expect(label).toHaveClass('custom-class') }) it('should apply custom labelClassName', () => { - // Arrange & Act render(<CheckboxWithLabel {...defaultProps} labelClassName="custom-label-class" />) - // Assert const labelText = screen.getByText('Test Label') expect(labelText).toHaveClass('custom-label-class') }) @@ -112,33 +95,26 @@ describe('CheckboxWithLabel', () => { describe('User Interactions', () => { it('should call onChange with true when clicking unchecked checkbox', () => { - // Arrange const mockOnChange = vi.fn() const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={false} onChange={mockOnChange} />) - // Act const checkbox = container.querySelector('[data-testid^="checkbox"]')! fireEvent.click(checkbox) - // Assert expect(mockOnChange).toHaveBeenCalledWith(true) }) it('should call onChange with false when clicking checked checkbox', () => { - // Arrange const mockOnChange = vi.fn() const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={true} onChange={mockOnChange} />) - // Act const checkbox = container.querySelector('[data-testid^="checkbox"]')! fireEvent.click(checkbox) - // Assert expect(mockOnChange).toHaveBeenCalledWith(false) }) it('should not trigger onChange when clicking label text due to custom checkbox', () => { - // Arrange const mockOnChange = vi.fn() render(<CheckboxWithLabel {...defaultProps} onChange={mockOnChange} />) @@ -152,9 +128,7 @@ describe('CheckboxWithLabel', () => { }) }) -// ========================================== // CrawledResultItem Tests -// ========================================== describe('CrawledResultItem', () => { const defaultProps = { payload: createMockCrawlResultItem(), @@ -171,16 +145,13 @@ describe('CrawledResultItem', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<CrawledResultItem {...defaultProps} />) - // Assert expect(screen.getByText('Test Page Title')).toBeInTheDocument() expect(screen.getByText('https://example.com/page1')).toBeInTheDocument() }) it('should render checkbox when isMultipleChoice is true', () => { - // Arrange & Act const { container } = render(<CrawledResultItem {...defaultProps} isMultipleChoice={true} />) // Assert - Custom checkbox uses data-testid @@ -189,7 +160,6 @@ describe('CrawledResultItem', () => { }) it('should render radio when isMultipleChoice is false', () => { - // Arrange & Act const { container } = render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />) // Assert - Radio component has size-4 rounded-full classes @@ -198,7 +168,6 @@ describe('CrawledResultItem', () => { }) it('should render checkbox as checked when isChecked is true', () => { - // Arrange & Act const { container } = render(<CrawledResultItem {...defaultProps} isChecked={true} />) // Assert - Checked state shows check icon @@ -207,35 +176,27 @@ describe('CrawledResultItem', () => { }) it('should render preview button when showPreview is true', () => { - // Arrange & Act render(<CrawledResultItem {...defaultProps} showPreview={true} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should not render preview button when showPreview is false', () => { - // Arrange & Act render(<CrawledResultItem {...defaultProps} showPreview={false} />) - // Assert expect(screen.queryByRole('button')).not.toBeInTheDocument() }) it('should apply active background when isPreview is true', () => { - // Arrange & Act const { container } = render(<CrawledResultItem {...defaultProps} isPreview={true} />) - // Assert const item = container.firstChild expect(item).toHaveClass('bg-state-base-active') }) it('should apply hover styles when isPreview is false', () => { - // Arrange & Act const { container } = render(<CrawledResultItem {...defaultProps} isPreview={false} />) - // Assert const item = container.firstChild expect(item).toHaveClass('group') expect(item).toHaveClass('hover:bg-state-base-hover') @@ -244,35 +205,26 @@ describe('CrawledResultItem', () => { describe('Props', () => { it('should display payload title', () => { - // Arrange const payload = createMockCrawlResultItem({ title: 'Custom Title' }) - // Act render(<CrawledResultItem {...defaultProps} payload={payload} />) - // Assert expect(screen.getByText('Custom Title')).toBeInTheDocument() }) it('should display payload source_url', () => { - // Arrange const payload = createMockCrawlResultItem({ source_url: 'https://custom.url/path' }) - // Act render(<CrawledResultItem {...defaultProps} payload={payload} />) - // Assert expect(screen.getByText('https://custom.url/path')).toBeInTheDocument() }) it('should set title attribute for truncation tooltip', () => { - // Arrange const payload = createMockCrawlResultItem({ title: 'Very Long Title' }) - // Act render(<CrawledResultItem {...defaultProps} payload={payload} />) - // Assert const titleElement = screen.getByText('Very Long Title') expect(titleElement).toHaveAttribute('title', 'Very Long Title') }) @@ -280,7 +232,6 @@ describe('CrawledResultItem', () => { describe('User Interactions', () => { it('should call onCheckChange with true when clicking unchecked checkbox', () => { - // Arrange const mockOnCheckChange = vi.fn() const { container } = render( <CrawledResultItem @@ -290,16 +241,13 @@ describe('CrawledResultItem', () => { />, ) - // Act const checkbox = container.querySelector('[data-testid^="checkbox"]')! fireEvent.click(checkbox) - // Assert expect(mockOnCheckChange).toHaveBeenCalledWith(true) }) it('should call onCheckChange with false when clicking checked checkbox', () => { - // Arrange const mockOnCheckChange = vi.fn() const { container } = render( <CrawledResultItem @@ -309,28 +257,22 @@ describe('CrawledResultItem', () => { />, ) - // Act const checkbox = container.querySelector('[data-testid^="checkbox"]')! fireEvent.click(checkbox) - // Assert expect(mockOnCheckChange).toHaveBeenCalledWith(false) }) it('should call onPreview when clicking preview button', () => { - // Arrange const mockOnPreview = vi.fn() render(<CrawledResultItem {...defaultProps} onPreview={mockOnPreview} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnPreview).toHaveBeenCalled() }) it('should toggle radio state when isMultipleChoice is false', () => { - // Arrange const mockOnCheckChange = vi.fn() const { container } = render( <CrawledResultItem @@ -345,15 +287,12 @@ describe('CrawledResultItem', () => { const radio = container.querySelector('.size-4.rounded-full')! fireEvent.click(radio) - // Assert expect(mockOnCheckChange).toHaveBeenCalledWith(true) }) }) }) -// ========================================== // CrawledResult Tests -// ========================================== describe('CrawledResult', () => { const defaultProps = { list: createMockCrawlResultItems(3), @@ -368,7 +307,6 @@ describe('CrawledResult', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} />) // Assert - Check for time info which contains total count @@ -376,17 +314,14 @@ describe('CrawledResult', () => { }) it('should render all list items', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} />) - // Assert expect(screen.getByText('Page 1')).toBeInTheDocument() expect(screen.getByText('Page 2')).toBeInTheDocument() expect(screen.getByText('Page 3')).toBeInTheDocument() }) it('should display scrape time info', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} usedTime={2.5} />) // Assert - Check for the time display @@ -394,7 +329,6 @@ describe('CrawledResult', () => { }) it('should render select all checkbox when isMultipleChoice is true', () => { - // Arrange & Act const { container } = render(<CrawledResult {...defaultProps} isMultipleChoice={true} />) // Assert - Multiple custom checkboxes (select all + items) @@ -403,7 +337,6 @@ describe('CrawledResult', () => { }) it('should not render select all checkbox when isMultipleChoice is false', () => { - // Arrange & Act const { container } = render(<CrawledResult {...defaultProps} isMultipleChoice={false} />) // Assert - No select all checkbox, only radio buttons for items @@ -415,38 +348,30 @@ describe('CrawledResult', () => { }) it('should show "Select All" when not all items are checked', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} checkedList={[]} />) - // Assert expect(screen.getByText(/selectAll|Select All/i)).toBeInTheDocument() }) it('should show "Reset All" when all items are checked', () => { - // Arrange const allChecked = createMockCrawlResultItems(3) - // Act render(<CrawledResult {...defaultProps} checkedList={allChecked} />) - // Assert expect(screen.getByText(/resetAll|Reset All/i)).toBeInTheDocument() }) }) describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( <CrawledResult {...defaultProps} className="custom-class" />, ) - // Assert expect(container.firstChild).toHaveClass('custom-class') }) it('should highlight item at previewIndex', () => { - // Arrange & Act const { container } = render( <CrawledResult {...defaultProps} previewIndex={1} />, ) @@ -457,7 +382,6 @@ describe('CrawledResult', () => { }) it('should pass showPreview to items', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} showPreview={true} />) // Assert - Preview buttons should be visible @@ -466,17 +390,14 @@ describe('CrawledResult', () => { }) it('should not show preview buttons when showPreview is false', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} showPreview={false} />) - // Assert expect(screen.queryByRole('button')).not.toBeInTheDocument() }) }) describe('User Interactions', () => { it('should call onSelectedChange with all items when clicking select all', () => { - // Arrange const mockOnSelectedChange = vi.fn() const list = createMockCrawlResultItems(3) const { container } = render( @@ -492,12 +413,10 @@ describe('CrawledResult', () => { const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') fireEvent.click(checkboxes[0]) - // Assert expect(mockOnSelectedChange).toHaveBeenCalledWith(list) }) it('should call onSelectedChange with empty array when clicking reset all', () => { - // Arrange const mockOnSelectedChange = vi.fn() const list = createMockCrawlResultItems(3) const { container } = render( @@ -509,16 +428,13 @@ describe('CrawledResult', () => { />, ) - // Act const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') fireEvent.click(checkboxes[0]) - // Assert expect(mockOnSelectedChange).toHaveBeenCalledWith([]) }) it('should add item to checkedList when checking unchecked item', () => { - // Arrange const mockOnSelectedChange = vi.fn() const list = createMockCrawlResultItems(3) const { container } = render( @@ -534,12 +450,10 @@ describe('CrawledResult', () => { const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') fireEvent.click(checkboxes[2]) - // Assert expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0], list[1]]) }) it('should remove item from checkedList when unchecking checked item', () => { - // Arrange const mockOnSelectedChange = vi.fn() const list = createMockCrawlResultItems(3) const { container } = render( @@ -555,12 +469,10 @@ describe('CrawledResult', () => { const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') fireEvent.click(checkboxes[1]) - // Assert expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]]) }) it('should replace selection when checking in single choice mode', () => { - // Arrange const mockOnSelectedChange = vi.fn() const list = createMockCrawlResultItems(3) const { container } = render( @@ -582,7 +494,6 @@ describe('CrawledResult', () => { }) it('should call onPreview with item and index when clicking preview', () => { - // Arrange const mockOnPreview = vi.fn() const list = createMockCrawlResultItems(3) render( @@ -594,11 +505,9 @@ describe('CrawledResult', () => { />, ) - // Act const buttons = screen.getAllByRole('button') fireEvent.click(buttons[1]) // Second item's preview button - // Assert expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1) }) @@ -625,7 +534,6 @@ describe('CrawledResult', () => { describe('Edge Cases', () => { it('should handle empty list', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} list={[]} usedTime={0.5} />) // Assert - Should show time info with 0 count @@ -633,29 +541,22 @@ describe('CrawledResult', () => { }) it('should handle single item list', () => { - // Arrange const singleItem = [createMockCrawlResultItem()] - // Act render(<CrawledResult {...defaultProps} list={singleItem} />) - // Assert expect(screen.getByText('Test Page Title')).toBeInTheDocument() }) it('should format usedTime to one decimal place', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} usedTime={1.567} />) - // Assert expect(screen.getByText(/1.6/)).toBeInTheDocument() }) }) }) -// ========================================== // Crawling Tests -// ========================================== describe('Crawling', () => { const defaultProps = { crawledNum: 5, @@ -668,23 +569,18 @@ describe('Crawling', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<Crawling {...defaultProps} />) - // Assert expect(screen.getByText(/5\/10/)).toBeInTheDocument() }) it('should display crawled count and total', () => { - // Arrange & Act render(<Crawling crawledNum={3} totalNum={15} />) - // Assert expect(screen.getByText(/3\/15/)).toBeInTheDocument() }) it('should render skeleton items', () => { - // Arrange & Act const { container } = render(<Crawling {...defaultProps} />) // Assert - Should have 3 skeleton items @@ -693,10 +589,8 @@ describe('Crawling', () => { }) it('should render header skeleton block', () => { - // Arrange & Act const { container } = render(<Crawling {...defaultProps} />) - // Assert const headerBlocks = container.querySelectorAll('.px-4.py-2 .bg-text-quaternary') expect(headerBlocks.length).toBeGreaterThan(0) }) @@ -704,35 +598,28 @@ describe('Crawling', () => { describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( <Crawling {...defaultProps} className="custom-crawling-class" />, ) - // Assert expect(container.firstChild).toHaveClass('custom-crawling-class') }) it('should handle zero values', () => { - // Arrange & Act render(<Crawling crawledNum={0} totalNum={0} />) - // Assert expect(screen.getByText(/0\/0/)).toBeInTheDocument() }) it('should handle large numbers', () => { - // Arrange & Act render(<Crawling crawledNum={999} totalNum={1000} />) - // Assert expect(screen.getByText(/999\/1000/)).toBeInTheDocument() }) }) describe('Skeleton Structure', () => { it('should render blocks with correct width classes', () => { - // Arrange & Act const { container } = render(<Crawling {...defaultProps} />) // Assert - Check for various width classes @@ -743,9 +630,7 @@ describe('Crawling', () => { }) }) -// ========================================== // ErrorMessage Tests -// ========================================== describe('ErrorMessage', () => { const defaultProps = { title: 'Error Title', @@ -757,41 +642,32 @@ describe('ErrorMessage', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<ErrorMessage {...defaultProps} />) - // Assert expect(screen.getByText('Error Title')).toBeInTheDocument() }) it('should render error icon', () => { - // Arrange & Act const { container } = render(<ErrorMessage {...defaultProps} />) - // Assert const icon = container.querySelector('svg') expect(icon).toBeInTheDocument() expect(icon).toHaveClass('text-text-destructive') }) it('should render title', () => { - // Arrange & Act render(<ErrorMessage title="Custom Error Title" />) - // Assert expect(screen.getByText('Custom Error Title')).toBeInTheDocument() }) it('should render error message when provided', () => { - // Arrange & Act render(<ErrorMessage {...defaultProps} errorMsg="Detailed error description" />) - // Assert expect(screen.getByText('Detailed error description')).toBeInTheDocument() }) it('should not render error message when not provided', () => { - // Arrange & Act render(<ErrorMessage {...defaultProps} />) // Assert - Should only have title, not error message container @@ -802,17 +678,14 @@ describe('ErrorMessage', () => { describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( <ErrorMessage {...defaultProps} className="custom-error-class" />, ) - // Assert expect(container.firstChild).toHaveClass('custom-error-class') }) it('should render with empty errorMsg', () => { - // Arrange & Act render(<ErrorMessage {...defaultProps} errorMsg="" />) // Assert - Empty string should not render message div @@ -820,64 +693,47 @@ describe('ErrorMessage', () => { }) it('should handle long title text', () => { - // Arrange const longTitle = 'This is a very long error title that might wrap to multiple lines' - // Act render(<ErrorMessage title={longTitle} />) - // Assert expect(screen.getByText(longTitle)).toBeInTheDocument() }) it('should handle long error message', () => { - // Arrange const longErrorMsg = 'This is a very detailed error message explaining what went wrong and how to fix it. It contains multiple sentences.' - // Act render(<ErrorMessage {...defaultProps} errorMsg={longErrorMsg} />) - // Assert expect(screen.getByText(longErrorMsg)).toBeInTheDocument() }) }) describe('Styling', () => { it('should have error background styling', () => { - // Arrange & Act const { container } = render(<ErrorMessage {...defaultProps} />) - // Assert expect(container.firstChild).toHaveClass('bg-toast-error-bg') }) it('should have border styling', () => { - // Arrange & Act const { container } = render(<ErrorMessage {...defaultProps} />) - // Assert expect(container.firstChild).toHaveClass('border-components-panel-border') }) it('should have rounded corners', () => { - // Arrange & Act const { container } = render(<ErrorMessage {...defaultProps} />) - // Assert expect(container.firstChild).toHaveClass('rounded-xl') }) }) }) -// ========================================== -// Integration Tests -// ========================================== describe('Base Components Integration', () => { it('should render CrawledResult with CrawledResultItem children', () => { - // Arrange const list = createMockCrawlResultItems(2) - // Act render( <CrawledResult list={list} @@ -893,10 +749,8 @@ describe('Base Components Integration', () => { }) it('should render CrawledResult with CheckboxWithLabel for select all', () => { - // Arrange const list = createMockCrawlResultItems(2) - // Act const { container } = render( <CrawledResult list={list} @@ -913,7 +767,6 @@ describe('Base Components Integration', () => { }) it('should allow selecting and previewing items', () => { - // Arrange const list = createMockCrawlResultItems(3) const mockOnSelectedChange = vi.fn() const mockOnPreview = vi.fn() @@ -933,14 +786,12 @@ describe('Base Components Integration', () => { const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') fireEvent.click(checkboxes[1]) - // Assert expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0]]) // Act - Preview second item const previewButtons = screen.getAllByRole('button') fireEvent.click(previewButtons[1]) - // Assert expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx index b89114c84b..c147e969a6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx @@ -6,13 +6,7 @@ import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/ty import Toast from '@/app/components/base/toast' import { CrawlStep } from '@/models/datasets' import { PipelineInputVarType } from '@/models/pipeline' -import Options from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import Options from '../index' // Mock useInitialData and useConfigurations hooks const { mockUseInitialData, mockUseConfigurations } = vi.hoisted(() => ({ @@ -28,15 +22,16 @@ vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ // Mock BaseField const mockBaseField = vi.fn() vi.mock('@/app/components/base/form/form-scenarios/base/field', () => { - const MockBaseFieldFactory = (props: any) => { + const MockBaseFieldFactory = (props: Record<string, unknown>) => { mockBaseField(props) - const MockField = ({ form }: { form: any }) => ( - <div data-testid={`field-${props.config?.variable || 'unknown'}`}> - <span data-testid={`field-label-${props.config?.variable}`}>{props.config?.label}</span> + const config = props.config as { variable?: string, label?: string } | undefined + const MockField = ({ form }: { form: { getFieldValue?: (field: string) => string, setFieldValue?: (field: string, value: string) => void } }) => ( + <div data-testid={`field-${config?.variable || 'unknown'}`}> + <span data-testid={`field-label-${config?.variable}`}>{config?.label}</span> <input - data-testid={`field-input-${props.config?.variable}`} - value={form.getFieldValue?.(props.config?.variable) || ''} - onChange={e => form.setFieldValue?.(props.config?.variable, e.target.value)} + data-testid={`field-input-${config?.variable}`} + value={form.getFieldValue?.(config?.variable || '') || ''} + onChange={e => form.setFieldValue?.(config?.variable || '', e.target.value)} /> </div> ) @@ -47,9 +42,9 @@ vi.mock('@/app/components/base/form/form-scenarios/base/field', () => { // Mock useAppForm const mockHandleSubmit = vi.fn() -const mockFormValues: Record<string, any> = {} +const mockFormValues: Record<string, unknown> = {} vi.mock('@/app/components/base/form', () => ({ - useAppForm: (options: any) => { + useAppForm: (options: { validators?: { onSubmit?: (arg: { value: Record<string, unknown> }) => unknown }, onSubmit?: (arg: { value: Record<string, unknown> }) => void }) => { const formOptions = options return { handleSubmit: () => { @@ -60,17 +55,13 @@ vi.mock('@/app/components/base/form', () => ({ } }, getFieldValue: (field: string) => mockFormValues[field], - setFieldValue: (field: string, value: any) => { + setFieldValue: (field: string, value: unknown) => { mockFormValues[field] = value }, } }, })) -// ========================================== -// Test Data Builders -// ========================================== - const createMockVariable = (overrides?: Partial<RAGPipelineVariables[0]>): RAGPipelineVariables[0] => ({ belong_to_node_id: 'node-1', type: PipelineInputVarType.textInput, @@ -91,7 +82,18 @@ const createMockVariables = (count = 1): RAGPipelineVariables => { })) } -const createMockConfiguration = (overrides?: Partial<any>): any => ({ +type MockConfiguration = { + type: BaseFieldType + variable: string + label: string + required: boolean + maxLength: number + options: unknown[] + showConditions: unknown[] + placeholder: string +} + +const createMockConfiguration = (overrides?: Partial<MockConfiguration>): MockConfiguration => ({ type: BaseFieldType.textInput, variable: 'test_variable', label: 'Test Label', @@ -113,9 +115,6 @@ const createDefaultProps = (overrides?: Partial<OptionsProps>): OptionsProps => ...overrides, }) -// ========================================== -// Test Suites -// ========================================== describe('Options', () => { let toastNotifySpy: MockInstance @@ -137,46 +136,33 @@ describe('Options', () => { toastNotifySpy.mockRestore() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) it('should render options header with toggle text', () => { - // Arrange const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(screen.getByText(/options/i)).toBeInTheDocument() }) it('should render Run button', () => { - // Arrange const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() expect(screen.getByText(/run/i)).toBeInTheDocument() }) it('should render form fields when not folded', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'url', label: 'URL' }), createMockConfiguration({ variable: 'depth', label: 'Depth' }), @@ -184,19 +170,15 @@ describe('Options', () => { mockUseConfigurations.mockReturnValue(configurations) const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(screen.getByTestId('field-url')).toBeInTheDocument() expect(screen.getByTestId('field-depth')).toBeInTheDocument() }) it('should render arrow icon in correct orientation when expanded', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) // Assert - Arrow should not have -rotate-90 class when expanded @@ -206,37 +188,27 @@ describe('Options', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('variables prop', () => { it('should pass variables to useInitialData hook', () => { - // Arrange const variables = createMockVariables(3) const props = createDefaultProps({ variables }) - // Act render(<Options {...props} />) - // Assert expect(mockUseInitialData).toHaveBeenCalledWith(variables) }) it('should pass variables to useConfigurations hook', () => { - // Arrange const variables = createMockVariables(2) const props = createDefaultProps({ variables }) - // Act render(<Options {...props} />) - // Assert expect(mockUseConfigurations).toHaveBeenCalledWith(variables) }) it('should render correct number of fields based on configurations', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field_1', label: 'Field 1' }), createMockConfiguration({ variable: 'field_2', label: 'Field 2' }), @@ -245,24 +217,19 @@ describe('Options', () => { mockUseConfigurations.mockReturnValue(configurations) const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(screen.getByTestId('field-field_1')).toBeInTheDocument() expect(screen.getByTestId('field-field_2')).toBeInTheDocument() expect(screen.getByTestId('field-field_3')).toBeInTheDocument() }) it('should handle empty variables array', () => { - // Arrange mockUseConfigurations.mockReturnValue([]) const props = createDefaultProps({ variables: [] }) - // Act const { container } = render(<Options {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() expect(screen.queryByTestId(/field-/)).not.toBeInTheDocument() }) @@ -270,54 +237,40 @@ describe('Options', () => { describe('step prop', () => { it('should show "Run" text when step is init', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.init }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByText(/run/i)).toBeInTheDocument() }) it('should show "Running" text when step is running', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.running }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByText(/running/i)).toBeInTheDocument() }) it('should disable button when step is running', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.running }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should enable button when step is finished', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.finished, runDisabled: false }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) it('should show loading state on button when step is running', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.running }) - // Act render(<Options {...props} />) // Assert - Button should have loading prop which disables it @@ -328,47 +281,35 @@ describe('Options', () => { describe('runDisabled prop', () => { it('should disable button when runDisabled is true', () => { - // Arrange const props = createDefaultProps({ runDisabled: true }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should enable button when runDisabled is false and step is not running', () => { - // Arrange const props = createDefaultProps({ runDisabled: false, step: CrawlStep.init }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) it('should disable button when both runDisabled is true and step is running', () => { - // Arrange const props = createDefaultProps({ runDisabled: true, step: CrawlStep.running }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should default runDisabled to undefined (falsy)', () => { - // Arrange const props = createDefaultProps() - delete (props as any).runDisabled + delete (props as Partial<OptionsProps>).runDisabled - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) }) @@ -385,16 +326,13 @@ describe('Options', () => { const mockOnSubmit = vi.fn() const props = createDefaultProps({ onSubmit: mockOnSubmit }) - // Act render(<Options {...props} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).toHaveBeenCalled() }) it('should not call onSubmit when validation fails', () => { - // Arrange const mockOnSubmit = vi.fn() // Create a required field configuration const requiredConfig = createMockConfiguration({ @@ -407,11 +345,9 @@ describe('Options', () => { // mockFormValues is empty, so required field validation will fail const props = createDefaultProps({ onSubmit: mockOnSubmit }) - // Act render(<Options {...props} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).not.toHaveBeenCalled() }) @@ -427,22 +363,17 @@ describe('Options', () => { const mockOnSubmit = vi.fn() const props = createDefaultProps({ onSubmit: mockOnSubmit }) - // Act render(<Options {...props} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).toHaveBeenCalledWith({ url: 'https://example.com', depth: 2 }) }) }) }) - // ========================================== // Side Effects and Cleanup (useEffect) - // ========================================== describe('Side Effects and Cleanup', () => { it('should expand options when step changes to init', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.finished }) const { rerender, container } = render(<Options {...props} />) @@ -456,7 +387,6 @@ describe('Options', () => { }) it('should collapse options when step changes to running', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.init }) const { rerender, container } = render(<Options {...props} />) @@ -473,7 +403,6 @@ describe('Options', () => { }) it('should collapse options when step changes to finished', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.init }) const { rerender, container } = render(<Options {...props} />) @@ -487,7 +416,6 @@ describe('Options', () => { }) it('should respond to step transitions from init -> running -> finished', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.init }) const { rerender, container } = render(<Options {...props} />) @@ -512,7 +440,6 @@ describe('Options', () => { }) it('should expand when step transitions from finished to init', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.finished }) const { rerender } = render(<Options {...props} />) @@ -527,12 +454,9 @@ describe('Options', () => { }) }) - // ========================================== // Memoization Logic and Dependencies - // ========================================== describe('Memoization Logic and Dependencies', () => { it('should regenerate schema when configurations change', () => { - // Arrange const config1 = [createMockConfiguration({ variable: 'url' })] const config2 = [createMockConfiguration({ variable: 'depth' })] mockUseConfigurations.mockReturnValue(config1) @@ -551,10 +475,8 @@ describe('Options', () => { }) it('should compute isRunning correctly for init step', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.init }) - // Act render(<Options {...props} />) // Assert - Button should not be in loading state @@ -564,10 +486,8 @@ describe('Options', () => { }) it('should compute isRunning correctly for running step', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.running }) - // Act render(<Options {...props} />) // Assert - Button should be in loading state @@ -577,10 +497,8 @@ describe('Options', () => { }) it('should compute isRunning correctly for finished step', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.finished }) - // Act render(<Options {...props} />) // Assert - Button should not be in loading state @@ -606,12 +524,9 @@ describe('Options', () => { }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions and Event Handlers', () => { it('should toggle fold state when header is clicked', () => { - // Arrange const props = createDefaultProps() render(<Options {...props} />) @@ -632,11 +547,9 @@ describe('Options', () => { }) it('should prevent default and stop propagation on form submit', () => { - // Arrange const props = createDefaultProps() const { container } = render(<Options {...props} />) - // Act const form = container.querySelector('form')! const mockPreventDefault = vi.fn() const mockStopPropagation = vi.fn() @@ -662,15 +575,12 @@ describe('Options', () => { const props = createDefaultProps({ onSubmit: mockOnSubmit }) render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).toHaveBeenCalled() }) it('should not trigger submit when button is disabled', () => { - // Arrange const mockOnSubmit = vi.fn() const props = createDefaultProps({ onSubmit: mockOnSubmit, runDisabled: true }) render(<Options {...props} />) @@ -678,12 +588,10 @@ describe('Options', () => { // Act - Try to click disabled button fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).not.toHaveBeenCalled() }) it('should maintain fold state after form submission', () => { - // Arrange const props = createDefaultProps() render(<Options {...props} />) @@ -698,7 +606,6 @@ describe('Options', () => { }) it('should allow clicking on arrow icon container to toggle', () => { - // Arrange const props = createDefaultProps() const { container } = render(<Options {...props} />) @@ -714,9 +621,6 @@ describe('Options', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle validation error and show toast', () => { // Arrange - Create required field that will fail validation when empty @@ -731,7 +635,6 @@ describe('Options', () => { const props = createDefaultProps() render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - Toast should be called with error message @@ -754,7 +657,6 @@ describe('Options', () => { const props = createDefaultProps() render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - Toast message should contain field path @@ -767,11 +669,9 @@ describe('Options', () => { }) it('should handle empty variables gracefully', () => { - // Arrange mockUseConfigurations.mockReturnValue([]) const props = createDefaultProps({ variables: [] }) - // Act const { container } = render(<Options {...props} />) // Assert - Should render without errors @@ -780,29 +680,23 @@ describe('Options', () => { }) it('should handle single variable configuration', () => { - // Arrange const singleConfig = [createMockConfiguration({ variable: 'only_field' })] mockUseConfigurations.mockReturnValue(singleConfig) const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(screen.getByTestId('field-only_field')).toBeInTheDocument() }) it('should handle many configurations', () => { - // Arrange const manyConfigs = Array.from({ length: 10 }, (_, i) => createMockConfiguration({ variable: `field_${i}`, label: `Field ${i}` })) mockUseConfigurations.mockReturnValue(manyConfigs) const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert for (let i = 0; i < 10; i++) expect(screen.getByTestId(`field-field_${i}`)).toBeInTheDocument() }) @@ -817,7 +711,6 @@ describe('Options', () => { const props = createDefaultProps() render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - Toast should be called once (only first error) @@ -830,7 +723,6 @@ describe('Options', () => { }) it('should handle validation pass when all required fields have values', () => { - // Arrange const requiredConfig = createMockConfiguration({ variable: 'url', label: 'URL', @@ -843,7 +735,6 @@ describe('Options', () => { const props = createDefaultProps({ onSubmit: mockOnSubmit }) render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - No toast error, onSubmit called @@ -852,17 +743,15 @@ describe('Options', () => { }) it('should handle undefined variables gracefully', () => { - // Arrange mockUseInitialData.mockReturnValue({}) mockUseConfigurations.mockReturnValue([]) - const props = createDefaultProps({ variables: undefined as any }) + const props = createDefaultProps({ variables: undefined as unknown as RAGPipelineVariables }) // Act & Assert - Should not throw expect(() => render(<Options {...props} />)).not.toThrow() }) it('should handle rapid fold/unfold toggling', () => { - // Arrange const props = createDefaultProps() render(<Options {...props} />) @@ -876,9 +765,7 @@ describe('Options', () => { }) }) - // ========================================== // All Prop Variations - // ========================================== describe('Prop Variations', () => { it.each([ [{ step: CrawlStep.init, runDisabled: false }, false, 'run'], @@ -888,13 +775,10 @@ describe('Options', () => { [{ step: CrawlStep.finished, runDisabled: false }, false, 'run'], [{ step: CrawlStep.finished, runDisabled: true }, true, 'run'], ] as const)('should render correctly with step=%s, runDisabled=%s', (propVariation, expectedDisabled, expectedText) => { - // Arrange const props = createDefaultProps(propVariation) - // Act render(<Options {...props} />) - // Assert const button = screen.getByRole('button') if (expectedDisabled) expect(button).toBeDisabled() @@ -915,7 +799,6 @@ describe('Options', () => { }) it('should handle variables with different types', () => { - // Arrange const variables: RAGPipelineVariables = [ createMockVariable({ type: PipelineInputVarType.textInput, variable: 'text_field' }), createMockVariable({ type: PipelineInputVarType.paragraph, variable: 'paragraph_field' }), @@ -927,19 +810,15 @@ describe('Options', () => { mockUseConfigurations.mockReturnValue(configurations) const props = createDefaultProps({ variables }) - // Act render(<Options {...props} />) - // Assert variables.forEach((v) => { expect(screen.getByTestId(`field-${v.variable}`)).toBeInTheDocument() }) }) }) - // ========================================== // Form Validation - // ========================================== describe('Form Validation', () => { it('should pass validation with valid data', () => { // Arrange - Use non-required field so empty value passes @@ -953,10 +832,8 @@ describe('Options', () => { const props = createDefaultProps({ onSubmit: mockOnSubmit }) render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).toHaveBeenCalled() expect(toastNotifySpy).not.toHaveBeenCalled() }) @@ -974,10 +851,8 @@ describe('Options', () => { const props = createDefaultProps({ onSubmit: mockOnSubmit }) render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).not.toHaveBeenCalled() expect(toastNotifySpy).toHaveBeenCalled() }) @@ -994,10 +869,8 @@ describe('Options', () => { const props = createDefaultProps() render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(toastNotifySpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', @@ -1007,99 +880,75 @@ describe('Options', () => { }) }) - // ========================================== // Styling Tests - // ========================================== describe('Styling', () => { it('should apply correct container classes to form', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert const form = container.querySelector('form') expect(form).toHaveClass('w-full') }) it('should apply cursor-pointer class to toggle container', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert const toggleContainer = container.querySelector('.cursor-pointer') expect(toggleContainer).toBeInTheDocument() }) it('should apply select-none class to prevent text selection on toggle', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert const toggleContainer = container.querySelector('.select-none') expect(toggleContainer).toBeInTheDocument() }) it('should apply rotate class to arrow icon when folded', () => { - // Arrange const props = createDefaultProps() const { container } = render(<Options {...props} />) // Act - Fold the options fireEvent.click(screen.getByText(/options/i)) - // Assert const arrowIcon = container.querySelector('svg') expect(arrowIcon).toHaveClass('-rotate-90') }) it('should not apply rotate class to arrow icon when expanded', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert const arrowIcon = container.querySelector('svg') expect(arrowIcon).not.toHaveClass('-rotate-90') }) it('should apply border class to fields container when expanded', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert const fieldsContainer = container.querySelector('.border-t') expect(fieldsContainer).toBeInTheDocument() }) }) - // ========================================== // BaseField Integration - // ========================================== describe('BaseField Integration', () => { it('should pass correct props to BaseField factory', () => { - // Arrange const config = createMockConfiguration({ variable: 'test_var', label: 'Test Label' }) mockUseConfigurations.mockReturnValue([config]) mockUseInitialData.mockReturnValue({ test_var: 'default_value' }) const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(mockBaseField).toHaveBeenCalledWith( expect.objectContaining({ initialData: { test_var: 'default_value' }, @@ -1109,7 +958,6 @@ describe('Options', () => { }) it('should render unique key for each field', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field_a' }), createMockConfiguration({ variable: 'field_b' }), @@ -1118,7 +966,6 @@ describe('Options', () => { mockUseConfigurations.mockReturnValue(configurations) const props = createDefaultProps() - // Act render(<Options {...props} />) // Assert - All fields should be rendered (React would warn if keys aren't unique) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts new file mode 100644 index 0000000000..5776f597ab --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts @@ -0,0 +1,50 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAddDocumentsSteps } from '../use-add-documents-steps' + +describe('useAddDocumentsSteps', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should initialize with step 1', () => { + const { result } = renderHook(() => useAddDocumentsSteps()) + expect(result.current.currentStep).toBe(1) + }) + + it('should return 3 steps', () => { + const { result } = renderHook(() => useAddDocumentsSteps()) + expect(result.current.steps).toHaveLength(3) + }) + + it('should have correct step labels', () => { + const { result } = renderHook(() => useAddDocumentsSteps()) + const labels = result.current.steps.map(s => s.label) + expect(labels[0]).toContain('chooseDatasource') + expect(labels[1]).toContain('processDocuments') + expect(labels[2]).toContain('processingDocuments') + }) + + it('should increment step on handleNextStep', () => { + const { result } = renderHook(() => useAddDocumentsSteps()) + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + }) + + it('should decrement step on handleBackStep', () => { + const { result } = renderHook(() => useAddDocumentsSteps()) + act(() => { + result.current.handleNextStep() + }) + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(3) + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(2) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts new file mode 100644 index 0000000000..e6da4313f1 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts @@ -0,0 +1,204 @@ +import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' +import type { DataSourceNotionPageMap, NotionPage } from '@/models/common' +import type { CrawlResultItem, DocumentItem, FileItem } from '@/models/datasets' +import type { OnlineDriveFile } from '@/models/pipeline' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DatasourceType } from '@/models/pipeline' +import { createDataSourceStore } from '../../data-source/store' +import { useDatasourceActions } from '../use-datasource-actions' + +const mockRunPublishedPipeline = vi.fn() +vi.mock('@/service/use-pipeline', () => ({ + useRunPublishedPipeline: () => ({ + mutateAsync: mockRunPublishedPipeline, + isIdle: true, + isPending: false, + }), +})) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +describe('useDatasourceActions', () => { + let store: ReturnType<typeof createDataSourceStore> + const defaultParams = () => ({ + datasource: { nodeId: 'node-1', nodeData: { provider_type: DatasourceType.localFile } } as unknown as Datasource, + datasourceType: DatasourceType.localFile, + pipelineId: 'pipeline-1', + dataSourceStore: store, + setEstimateData: vi.fn(), + setBatchId: vi.fn(), + setDocuments: vi.fn(), + handleNextStep: vi.fn(), + PagesMapAndSelectedPagesId: {}, + currentWorkspacePages: undefined as { page_id: string }[] | undefined, + clearOnlineDocumentData: vi.fn(), + clearWebsiteCrawlData: vi.fn(), + clearOnlineDriveData: vi.fn(), + setDatasource: vi.fn(), + }) + + beforeEach(() => { + vi.clearAllMocks() + store = createDataSourceStore() + }) + + it('should return all action functions', () => { + const params = defaultParams() + const { result } = renderHook(() => useDatasourceActions(params)) + + expect(typeof result.current.onClickProcess).toBe('function') + expect(typeof result.current.onClickPreview).toBe('function') + expect(typeof result.current.handleSubmit).toBe('function') + expect(typeof result.current.handlePreviewFileChange).toBe('function') + expect(typeof result.current.handlePreviewOnlineDocumentChange).toBe('function') + expect(typeof result.current.handlePreviewWebsiteChange).toBe('function') + expect(typeof result.current.handlePreviewOnlineDriveFileChange).toBe('function') + expect(typeof result.current.handleSelectAll).toBe('function') + expect(typeof result.current.handleSwitchDataSource).toBe('function') + expect(typeof result.current.handleCredentialChange).toBe('function') + expect(result.current.isIdle).toBe(true) + expect(result.current.isPending).toBe(false) + }) + + it('should handle credential change by clearing data and setting new credential', () => { + const params = defaultParams() + const { result } = renderHook(() => useDatasourceActions(params)) + + act(() => { + result.current.handleCredentialChange('cred-new') + }) + + expect(store.getState().currentCredentialId).toBe('cred-new') + }) + + it('should handle switch data source', () => { + const params = defaultParams() + const newDatasource = { + nodeId: 'node-2', + nodeData: { provider_type: DatasourceType.onlineDocument }, + } as unknown as Datasource + + const { result } = renderHook(() => useDatasourceActions(params)) + act(() => { + result.current.handleSwitchDataSource(newDatasource) + }) + + expect(store.getState().currentCredentialId).toBe('') + expect(store.getState().currentNodeIdRef.current).toBe('node-2') + expect(params.setDatasource).toHaveBeenCalledWith(newDatasource) + }) + + it('should handle preview file change by updating ref', () => { + const params = defaultParams() + params.dataSourceStore = store + + const { result } = renderHook(() => useDatasourceActions(params)) + + // Set up formRef to prevent null error + result.current.formRef.current = { submit: vi.fn() } + + const file = { id: 'f1', name: 'test.pdf' } as unknown as DocumentItem + act(() => { + result.current.handlePreviewFileChange(file) + }) + + expect(store.getState().previewLocalFileRef.current).toEqual(file) + }) + + it('should handle preview online document change', () => { + const params = defaultParams() + const { result } = renderHook(() => useDatasourceActions(params)) + result.current.formRef.current = { submit: vi.fn() } + + const page = { page_id: 'p1', page_name: 'My Page' } as unknown as NotionPage + act(() => { + result.current.handlePreviewOnlineDocumentChange(page) + }) + + expect(store.getState().previewOnlineDocumentRef.current).toEqual(page) + }) + + it('should handle preview website change', () => { + const params = defaultParams() + const { result } = renderHook(() => useDatasourceActions(params)) + result.current.formRef.current = { submit: vi.fn() } + + const website = { title: 'Page', source_url: 'https://example.com' } as unknown as CrawlResultItem + act(() => { + result.current.handlePreviewWebsiteChange(website) + }) + + expect(store.getState().previewWebsitePageRef.current).toEqual(website) + }) + + it('should handle select all for online documents', () => { + const params = defaultParams() + params.datasourceType = DatasourceType.onlineDocument + params.currentWorkspacePages = [{ page_id: 'p1' }, { page_id: 'p2' }] as unknown as NotionPage[] + params.PagesMapAndSelectedPagesId = { + p1: { page_id: 'p1', page_name: 'A', workspace_id: 'w1' }, + p2: { page_id: 'p2', page_name: 'B', workspace_id: 'w1' }, + } as unknown as DataSourceNotionPageMap + + const { result } = renderHook(() => useDatasourceActions(params)) + + // First call: select all + act(() => { + result.current.handleSelectAll() + }) + expect(store.getState().onlineDocuments).toHaveLength(2) + + // Second call: deselect all + act(() => { + result.current.handleSelectAll() + }) + expect(store.getState().onlineDocuments).toEqual([]) + }) + + it('should handle select all for online drive', () => { + const params = defaultParams() + params.datasourceType = DatasourceType.onlineDrive + + store.getState().setOnlineDriveFileList([ + { id: 'f1', type: 'file' }, + { id: 'f2', type: 'file' }, + { id: 'b1', type: 'bucket' }, + ] as unknown as OnlineDriveFile[]) + + const { result } = renderHook(() => useDatasourceActions(params)) + + act(() => { + result.current.handleSelectAll() + }) + // Should select f1, f2 but not b1 (bucket) + expect(store.getState().selectedFileIds).toEqual(['f1', 'f2']) + }) + + it('should handle submit with preview mode', async () => { + const params = defaultParams() + store.getState().setLocalFileList([{ file: { id: 'f1', name: 'test.pdf' } }] as unknown as FileItem[]) + store.getState().previewLocalFileRef.current = { id: 'f1', name: 'test.pdf' } as unknown as DocumentItem + + mockRunPublishedPipeline.mockResolvedValue({ data: { outputs: { tokens: 100 } } }) + + const { result } = renderHook(() => useDatasourceActions(params)) + + // Set preview mode + result.current.isPreview.current = true + + await act(async () => { + await result.current.handleSubmit({ query: 'test' }) + }) + + expect(mockRunPublishedPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + pipeline_id: 'pipeline-1', + is_preview: true, + start_node_id: 'node-1', + }), + expect.anything(), + ) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts new file mode 100644 index 0000000000..7ecd4bf841 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts @@ -0,0 +1,58 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import type { Node } from '@/app/components/workflow/types' +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/components/workflow/types', async () => { + const actual = await vi.importActual<Record<string, unknown>>('@/app/components/workflow/types') + const blockEnum = actual.BlockEnum as Record<string, string> + return { + ...actual, + BlockEnum: { + ...blockEnum, + DataSource: 'data-source', + }, + } +}) + +const { useDatasourceOptions } = await import('../use-datasource-options') + +describe('useDatasourceOptions', () => { + const createNode = (id: string, title: string, type: string): Node<DataSourceNodeType> => ({ + id, + position: { x: 0, y: 0 }, + data: { + type, + title, + provider_type: 'local_file', + }, + } as unknown as Node<DataSourceNodeType>) + + it('should return empty array for no datasource nodes', () => { + const nodes = [ + createNode('n1', 'LLM Node', 'llm'), + ] + const { result } = renderHook(() => useDatasourceOptions(nodes)) + expect(result.current).toEqual([]) + }) + + it('should return options for datasource nodes', () => { + const nodes = [ + createNode('n1', 'File Upload', 'data-source'), + createNode('n2', 'Web Crawl', 'data-source'), + createNode('n3', 'LLM Node', 'llm'), + ] + const { result } = renderHook(() => useDatasourceOptions(nodes)) + expect(result.current).toHaveLength(2) + expect(result.current[0]).toEqual({ + label: 'File Upload', + value: 'n1', + data: expect.objectContaining({ title: 'File Upload' }), + }) + expect(result.current[1]).toEqual({ + label: 'Web Crawl', + value: 'n2', + data: expect.objectContaining({ title: 'Web Crawl' }), + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts new file mode 100644 index 0000000000..155b41541b --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts @@ -0,0 +1,207 @@ +import type { ReactNode } from 'react' +import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common' +import type { CrawlResultItem, CustomFile as File, FileItem } from '@/models/datasets' +import type { OnlineDriveFile } from '@/models/pipeline' +import { act, renderHook } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CrawlStep } from '@/models/datasets' +import { createDataSourceStore } from '../../data-source/store' +import { DataSourceContext } from '../../data-source/store/provider' +import { useLocalFile, useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from '../use-datasource-store' + +const createWrapper = (store: ReturnType<typeof createDataSourceStore>) => { + return ({ children }: { children: ReactNode }) => + React.createElement(DataSourceContext.Provider, { value: store }, children) +} + +describe('useLocalFile', () => { + let store: ReturnType<typeof createDataSourceStore> + + beforeEach(() => { + vi.clearAllMocks() + store = createDataSourceStore() + }) + + it('should return local file list and initial state', () => { + const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) }) + + expect(result.current.localFileList).toEqual([]) + expect(result.current.allFileLoaded).toBe(false) + expect(result.current.currentLocalFile).toBeUndefined() + }) + + it('should compute allFileLoaded when all files have ids', () => { + store.getState().setLocalFileList([ + { file: { id: 'f1', name: 'a.pdf' } }, + { file: { id: 'f2', name: 'b.pdf' } }, + ] as unknown as FileItem[]) + + const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) }) + expect(result.current.allFileLoaded).toBe(true) + }) + + it('should compute allFileLoaded as false when some files lack ids', () => { + store.getState().setLocalFileList([ + { file: { id: 'f1', name: 'a.pdf' } }, + { file: { id: '', name: 'b.pdf' } }, + ] as unknown as FileItem[]) + + const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) }) + expect(result.current.allFileLoaded).toBe(false) + }) + + it('should hide preview local file', () => { + store.getState().setCurrentLocalFile({ id: 'f1' } as unknown as File) + const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) }) + + act(() => { + result.current.hidePreviewLocalFile() + }) + expect(store.getState().currentLocalFile).toBeUndefined() + }) +}) + +describe('useOnlineDocument', () => { + let store: ReturnType<typeof createDataSourceStore> + + beforeEach(() => { + vi.clearAllMocks() + store = createDataSourceStore() + }) + + it('should return initial state', () => { + const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) }) + + expect(result.current.onlineDocuments).toEqual([]) + expect(result.current.currentDocument).toBeUndefined() + expect(result.current.currentWorkspace).toBeUndefined() + }) + + it('should build PagesMapAndSelectedPagesId from documentsData', () => { + store.getState().setDocumentsData([ + { workspace_id: 'w1', pages: [{ page_id: 'p1', page_name: 'Page 1' }] }, + ] as unknown as DataSourceNotionWorkspace[]) + + const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) }) + expect(result.current.PagesMapAndSelectedPagesId).toHaveProperty('p1') + expect(result.current.PagesMapAndSelectedPagesId.p1.workspace_id).toBe('w1') + }) + + it('should hide preview online document', () => { + store.getState().setCurrentDocument({ page_id: 'p1' } as unknown as NotionPage) + const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) }) + + act(() => { + result.current.hidePreviewOnlineDocument() + }) + expect(store.getState().currentDocument).toBeUndefined() + }) + + it('should clear online document data', () => { + store.getState().setDocumentsData([{ workspace_id: 'w1', pages: [] }] as unknown as DataSourceNotionWorkspace[]) + store.getState().setSearchValue('test') + store.getState().setOnlineDocuments([{ page_id: 'p1' }] as unknown as NotionPage[]) + + const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) }) + act(() => { + result.current.clearOnlineDocumentData() + }) + + expect(store.getState().documentsData).toEqual([]) + expect(store.getState().searchValue).toBe('') + expect(store.getState().onlineDocuments).toEqual([]) + }) +}) + +describe('useWebsiteCrawl', () => { + let store: ReturnType<typeof createDataSourceStore> + + beforeEach(() => { + vi.clearAllMocks() + store = createDataSourceStore() + }) + + it('should return initial state', () => { + const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) }) + + expect(result.current.websitePages).toEqual([]) + expect(result.current.currentWebsite).toBeUndefined() + }) + + it('should hide website preview', () => { + store.getState().setCurrentWebsite({ title: 'Test' } as unknown as CrawlResultItem) + store.getState().setPreviewIndex(2) + const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) }) + + act(() => { + result.current.hideWebsitePreview() + }) + + expect(store.getState().currentWebsite).toBeUndefined() + expect(store.getState().previewIndex).toBe(-1) + }) + + it('should clear website crawl data', () => { + store.getState().setStep(CrawlStep.running) + store.getState().setWebsitePages([{ title: 'Test' }] as unknown as CrawlResultItem[]) + + const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) }) + act(() => { + result.current.clearWebsiteCrawlData() + }) + + expect(store.getState().step).toBe(CrawlStep.init) + expect(store.getState().websitePages).toEqual([]) + expect(store.getState().currentWebsite).toBeUndefined() + }) +}) + +describe('useOnlineDrive', () => { + let store: ReturnType<typeof createDataSourceStore> + + beforeEach(() => { + vi.clearAllMocks() + store = createDataSourceStore() + }) + + it('should return initial state', () => { + const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) }) + + expect(result.current.onlineDriveFileList).toEqual([]) + expect(result.current.selectedFileIds).toEqual([]) + expect(result.current.selectedOnlineDriveFileList).toEqual([]) + }) + + it('should compute selected online drive file list', () => { + const files = [ + { id: 'f1', name: 'a.pdf' }, + { id: 'f2', name: 'b.pdf' }, + { id: 'f3', name: 'c.pdf' }, + ] as unknown as OnlineDriveFile[] + store.getState().setOnlineDriveFileList(files) + store.getState().setSelectedFileIds(['f1', 'f3']) + + const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) }) + expect(result.current.selectedOnlineDriveFileList).toEqual([files[0], files[2]]) + }) + + it('should clear online drive data', () => { + store.getState().setOnlineDriveFileList([{ id: 'f1' }] as unknown as OnlineDriveFile[]) + store.getState().setBucket('b1') + store.getState().setPrefix(['p1']) + store.getState().setKeywords('kw') + store.getState().setSelectedFileIds(['f1']) + + const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) }) + act(() => { + result.current.clearOnlineDriveData() + }) + + expect(store.getState().onlineDriveFileList).toEqual([]) + expect(store.getState().bucket).toBe('') + expect(store.getState().prefix).toEqual([]) + expect(store.getState().keywords).toBe('') + expect(store.getState().selectedFileIds).toEqual([]) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts new file mode 100644 index 0000000000..2032bb2c09 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts @@ -0,0 +1,205 @@ +import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' +import type { OnlineDriveFile } from '@/models/pipeline' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline' +import { useDatasourceUIState } from '../use-datasource-ui-state' + +describe('useDatasourceUIState', () => { + const defaultParams = { + datasource: { nodeData: { provider_type: DatasourceType.localFile } } as unknown as Datasource, + allFileLoaded: true, + localFileListLength: 3, + onlineDocumentsLength: 0, + websitePagesLength: 0, + selectedFileIdsLength: 0, + onlineDriveFileList: [] as OnlineDriveFile[], + isVectorSpaceFull: false, + enableBilling: false, + currentWorkspacePagesLength: 0, + fileUploadConfig: { file_size_limit: 50, batch_count_limit: 20 }, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('datasourceType', () => { + it('should return provider_type from datasource', () => { + const { result } = renderHook(() => useDatasourceUIState(defaultParams)) + expect(result.current.datasourceType).toBe(DatasourceType.localFile) + }) + + it('should return undefined when no datasource', () => { + const { result } = renderHook(() => + useDatasourceUIState({ ...defaultParams, datasource: undefined }), + ) + expect(result.current.datasourceType).toBeUndefined() + }) + }) + + describe('isShowVectorSpaceFull', () => { + it('should be false when billing disabled', () => { + const { result } = renderHook(() => + useDatasourceUIState({ ...defaultParams, isVectorSpaceFull: true }), + ) + expect(result.current.isShowVectorSpaceFull).toBe(false) + }) + + it('should be true when billing enabled and space is full for local file', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + isVectorSpaceFull: true, + enableBilling: true, + allFileLoaded: true, + }), + ) + expect(result.current.isShowVectorSpaceFull).toBe(true) + }) + + it('should be false when no datasource', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: undefined, + isVectorSpaceFull: true, + enableBilling: true, + }), + ) + expect(result.current.isShowVectorSpaceFull).toBe(false) + }) + }) + + describe('nextBtnDisabled', () => { + it('should be true when no datasource', () => { + const { result } = renderHook(() => + useDatasourceUIState({ ...defaultParams, datasource: undefined }), + ) + expect(result.current.nextBtnDisabled).toBe(true) + }) + + it('should be false when local files loaded', () => { + const { result } = renderHook(() => useDatasourceUIState(defaultParams)) + expect(result.current.nextBtnDisabled).toBe(false) + }) + + it('should be true when local file list empty', () => { + const { result } = renderHook(() => + useDatasourceUIState({ ...defaultParams, localFileListLength: 0 }), + ) + expect(result.current.nextBtnDisabled).toBe(true) + }) + + it('should be true when files not all loaded', () => { + const { result } = renderHook(() => + useDatasourceUIState({ ...defaultParams, allFileLoaded: false }), + ) + expect(result.current.nextBtnDisabled).toBe(true) + }) + + it('should be false for online document with documents selected', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + onlineDocumentsLength: 2, + }), + ) + expect(result.current.nextBtnDisabled).toBe(false) + }) + + it('should be true for online document with no documents', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + onlineDocumentsLength: 0, + }), + ) + expect(result.current.nextBtnDisabled).toBe(true) + }) + }) + + describe('showSelect', () => { + it('should be false for local file type', () => { + const { result } = renderHook(() => useDatasourceUIState(defaultParams)) + expect(result.current.showSelect).toBe(false) + }) + + it('should be true for online document with workspace pages', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + currentWorkspacePagesLength: 5, + }), + ) + expect(result.current.showSelect).toBe(true) + }) + + it('should be true for online drive with non-bucket files', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDrive } } as unknown as Datasource, + onlineDriveFileList: [ + { id: '1', name: 'file.txt', type: OnlineDriveFileType.file }, + ], + }), + ) + expect(result.current.showSelect).toBe(true) + }) + + it('should be false for online drive showing only buckets', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDrive } } as unknown as Datasource, + onlineDriveFileList: [ + { id: '1', name: 'bucket-1', type: OnlineDriveFileType.bucket }, + ], + }), + ) + expect(result.current.showSelect).toBe(false) + }) + }) + + describe('totalOptions and selectedOptions', () => { + it('should return workspace pages count for online document', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + currentWorkspacePagesLength: 10, + onlineDocumentsLength: 3, + }), + ) + expect(result.current.totalOptions).toBe(10) + expect(result.current.selectedOptions).toBe(3) + }) + + it('should return undefined for local file type', () => { + const { result } = renderHook(() => useDatasourceUIState(defaultParams)) + expect(result.current.totalOptions).toBeUndefined() + expect(result.current.selectedOptions).toBeUndefined() + }) + }) + + describe('tip', () => { + it('should return empty string for local file', () => { + const { result } = renderHook(() => useDatasourceUIState(defaultParams)) + expect(result.current.tip).toBe('') + }) + + it('should return tip for online document', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + }), + ) + expect(result.current.tip).toContain('selectOnlineDocumentTip') + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx index 127fdc3624..c98acc2086 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx @@ -5,7 +5,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { ChunkingMode } from '@/models/datasets' import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline' -import ChunkPreview from './chunk-preview' +import ChunkPreview from '../chunk-preview' // Uses global react-i18next mock from web/vitest.setup.ts @@ -18,7 +18,7 @@ vi.mock('@/context/dataset-detail', () => ({ })) // Mock document picker - needs mock for simplified interaction testing -vi.mock('../../../common/document-picker/preview-document-picker', () => ({ +vi.mock('../../../../common/document-picker/preview-document-picker', () => ({ default: ({ files, onChange, value }: { files: Array<{ id: string, name: string, extension: string }> onChange: (selected: { id: string, name: string, extension: string }) => void @@ -43,7 +43,6 @@ vi.mock('../../../common/document-picker/preview-document-picker', () => ({ ), })) -// Test data factories const createMockLocalFile = (overrides?: Partial<CustomFile>): CustomFile => ({ id: 'file-1', name: 'test-file.pdf', diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx new file mode 100644 index 0000000000..715d1650df --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx @@ -0,0 +1,68 @@ +import type { CustomFile } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import FilePreview from '../file-preview' + +const mockFileData = { content: 'file content here with some text' } +let mockIsFetching = false + +vi.mock('@/service/use-common', () => ({ + useFilePreview: () => ({ + data: mockIsFetching ? undefined : mockFileData, + isFetching: mockIsFetching, + }), +})) + +vi.mock('../../../../common/document-file-icon', () => ({ + default: () => <span data-testid="file-icon" />, +})) + +vi.mock('../loading', () => ({ + default: () => <div data-testid="loading" />, +})) + +describe('FilePreview', () => { + const defaultProps = { + file: { + id: 'file-1', + name: 'document.pdf', + extension: 'pdf', + size: 1024, + } as CustomFile, + hidePreview: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockIsFetching = false + }) + + it('should render preview label', () => { + render(<FilePreview {...defaultProps} />) + expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() + }) + + it('should render file name', () => { + render(<FilePreview {...defaultProps} />) + expect(screen.getByText('document.pdf')).toBeInTheDocument() + }) + + it('should render file content when loaded', () => { + render(<FilePreview {...defaultProps} />) + expect(screen.getByText('file content here with some text')).toBeInTheDocument() + }) + + it('should render loading state', () => { + mockIsFetching = true + render(<FilePreview {...defaultProps} />) + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('should call hidePreview when close button clicked', () => { + render(<FilePreview {...defaultProps} />) + const buttons = screen.getAllByRole('button') + const closeBtn = buttons[buttons.length - 1] + fireEvent.click(closeBtn) + expect(defaultProps.hidePreview).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx index 5375a0197c..947313cda5 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx @@ -2,7 +2,7 @@ import type { NotionPage } from '@/models/common' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import Toast from '@/app/components/base/toast' -import OnlineDocumentPreview from './online-document-preview' +import OnlineDocumentPreview from '../online-document-preview' // Uses global react-i18next mock from web/vitest.setup.ts @@ -29,7 +29,7 @@ const mockCurrentCredentialId = 'credential-123' const mockGetState = vi.fn(() => ({ currentCredentialId: mockCurrentCredentialId, })) -vi.mock('../data-source/store', () => ({ +vi.mock('../../data-source/store', () => ({ useDataSourceStore: () => ({ getState: mockGetState, }), diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/web-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/web-preview.spec.tsx new file mode 100644 index 0000000000..1f59e11035 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/web-preview.spec.tsx @@ -0,0 +1,48 @@ +import type { CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WebPreview from '../web-preview' + +describe('WebPreview', () => { + const defaultProps = { + currentWebsite: { + title: 'Test Page', + source_url: 'https://example.com', + markdown: 'Hello **markdown** content', + description: '', + } satisfies CrawlResultItem, + hidePreview: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render preview label', () => { + render(<WebPreview {...defaultProps} />) + expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() + }) + + it('should render page title', () => { + render(<WebPreview {...defaultProps} />) + expect(screen.getByText('Test Page')).toBeInTheDocument() + }) + + it('should render source URL', () => { + render(<WebPreview {...defaultProps} />) + expect(screen.getByText('https://example.com')).toBeInTheDocument() + }) + + it('should render markdown content', () => { + render(<WebPreview {...defaultProps} />) + expect(screen.getByText('Hello **markdown** content')).toBeInTheDocument() + }) + + it('should call hidePreview when close button clicked', () => { + render(<WebPreview {...defaultProps} />) + const buttons = screen.getAllByRole('button') + const closeBtn = buttons[buttons.length - 1] + fireEvent.click(closeBtn) + expect(defaultProps.hidePreview).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx deleted file mode 100644 index 6f040ffb00..0000000000 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import type { CustomFile as File } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import FilePreview from './file-preview' - -// Uses global react-i18next mock from web/vitest.setup.ts - -// Mock useFilePreview hook - needs to be mocked to control return values -const mockUseFilePreview = vi.fn() -vi.mock('@/service/use-common', () => ({ - useFilePreview: (fileID: string) => mockUseFilePreview(fileID), -})) - -// Test data factory -const createMockFile = (overrides?: Partial<File>): File => ({ - id: 'file-123', - name: 'test-document.pdf', - size: 2048, - type: 'application/pdf', - extension: 'pdf', - lastModified: Date.now(), - webkitRelativePath: '', - arrayBuffer: vi.fn() as () => Promise<ArrayBuffer>, - bytes: vi.fn() as () => Promise<Uint8Array>, - slice: vi.fn() as (start?: number, end?: number, contentType?: string) => Blob, - stream: vi.fn() as () => ReadableStream<Uint8Array>, - text: vi.fn() as () => Promise<string>, - ...overrides, -} as File) - -const createMockFilePreviewData = (content: string = 'This is the file content') => ({ - content, -}) - -const defaultProps = { - file: createMockFile(), - hidePreview: vi.fn(), -} - -describe('FilePreview', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseFilePreview.mockReturnValue({ - data: undefined, - isFetching: false, - }) - }) - - describe('Rendering', () => { - it('should render the component with file information', () => { - render(<FilePreview {...defaultProps} />) - - // i18n mock returns key by default - expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() - expect(screen.getByText('test-document.pdf')).toBeInTheDocument() - }) - - it('should display file extension in uppercase via CSS class', () => { - render(<FilePreview {...defaultProps} />) - - // The extension is displayed in the info section (as uppercase via CSS class) - const extensionElement = screen.getByText('pdf') - expect(extensionElement).toBeInTheDocument() - expect(extensionElement).toHaveClass('uppercase') - }) - - it('should display formatted file size', () => { - render(<FilePreview {...defaultProps} />) - - // Real formatFileSize: 2048 bytes => "2.00 KB" - expect(screen.getByText('2.00 KB')).toBeInTheDocument() - }) - - it('should render close button', () => { - render(<FilePreview {...defaultProps} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should call useFilePreview with correct fileID', () => { - const file = createMockFile({ id: 'specific-file-id' }) - - render(<FilePreview {...defaultProps} file={file} />) - - expect(mockUseFilePreview).toHaveBeenCalledWith('specific-file-id') - }) - }) - - describe('File Name Processing', () => { - it('should extract file name without extension', () => { - const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' }) - - render(<FilePreview {...defaultProps} file={file} />) - - // The displayed text is `${fileName}.${extension}`, where fileName is name without ext - // my-document.pdf -> fileName = 'my-document', displayed as 'my-document.pdf' - expect(screen.getByText('my-document.pdf')).toBeInTheDocument() - }) - - it('should handle file name with multiple dots', () => { - const file = createMockFile({ name: 'my.file.name.pdf', extension: 'pdf' }) - - render(<FilePreview {...defaultProps} file={file} />) - - // fileName = arr.slice(0, -1).join() = 'my,file,name', then displayed as 'my,file,name.pdf' - expect(screen.getByText('my,file,name.pdf')).toBeInTheDocument() - }) - - it('should handle empty file name', () => { - const file = createMockFile({ name: '', extension: '' }) - - render(<FilePreview {...defaultProps} file={file} />) - - // fileName = '', displayed as '.' - expect(screen.getByText('.')).toBeInTheDocument() - }) - - it('should handle file without extension in name', () => { - const file = createMockFile({ name: 'noextension', extension: '' }) - - render(<FilePreview {...defaultProps} file={file} />) - - // fileName = '' (slice returns empty for single element array), displayed as '.' - expect(screen.getByText('.')).toBeInTheDocument() - }) - }) - - describe('Loading State', () => { - it('should render loading component when fetching', () => { - mockUseFilePreview.mockReturnValue({ - data: undefined, - isFetching: true, - }) - - render(<FilePreview {...defaultProps} />) - - // Loading component renders skeleton - expect(document.querySelector('.overflow-hidden')).toBeInTheDocument() - }) - - it('should not render content when loading', () => { - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData('Some content'), - isFetching: true, - }) - - render(<FilePreview {...defaultProps} />) - - expect(screen.queryByText('Some content')).not.toBeInTheDocument() - }) - }) - - describe('Content Display', () => { - it('should render file content when loaded', () => { - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData('This is the file content'), - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - expect(screen.getByText('This is the file content')).toBeInTheDocument() - }) - - it('should display character count when data is available', () => { - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData('Hello'), // 5 characters - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - // Real formatNumberAbbreviated returns "5" for numbers < 1000 - expect(screen.getByText(/5/)).toBeInTheDocument() - }) - - it('should format large character counts', () => { - const longContent = 'a'.repeat(2500) - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData(longContent), - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - // Real formatNumberAbbreviated uses lowercase 'k': "2.5k" - expect(screen.getByText(/2\.5k/)).toBeInTheDocument() - }) - - it('should not display character count when data is not available', () => { - mockUseFilePreview.mockReturnValue({ - data: undefined, - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - // No character text shown - expect(screen.queryByText(/datasetPipeline\.addDocuments\.characters/)).not.toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call hidePreview when close button is clicked', () => { - const hidePreview = vi.fn() - - render(<FilePreview {...defaultProps} hidePreview={hidePreview} />) - - const closeButton = screen.getByRole('button') - fireEvent.click(closeButton) - - expect(hidePreview).toHaveBeenCalledTimes(1) - }) - }) - - describe('File Size Formatting', () => { - it('should format small file sizes in bytes', () => { - const file = createMockFile({ size: 500 }) - - render(<FilePreview {...defaultProps} file={file} />) - - // Real formatFileSize: 500 => "500.00 bytes" - expect(screen.getByText('500.00 bytes')).toBeInTheDocument() - }) - - it('should format kilobyte file sizes', () => { - const file = createMockFile({ size: 5120 }) - - render(<FilePreview {...defaultProps} file={file} />) - - // Real formatFileSize: 5120 => "5.00 KB" - expect(screen.getByText('5.00 KB')).toBeInTheDocument() - }) - - it('should format megabyte file sizes', () => { - const file = createMockFile({ size: 2097152 }) - - render(<FilePreview {...defaultProps} file={file} />) - - // Real formatFileSize: 2097152 => "2.00 MB" - expect(screen.getByText('2.00 MB')).toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle undefined file id', () => { - const file = createMockFile({ id: undefined }) - - render(<FilePreview {...defaultProps} file={file} />) - - expect(mockUseFilePreview).toHaveBeenCalledWith('') - }) - - it('should handle empty extension', () => { - const file = createMockFile({ extension: undefined }) - - render(<FilePreview {...defaultProps} file={file} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should handle zero file size', () => { - const file = createMockFile({ size: 0 }) - - render(<FilePreview {...defaultProps} file={file} />) - - // Real formatFileSize returns 0 for falsy values - // The component still renders - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should handle very long file content', () => { - const veryLongContent = 'a'.repeat(1000000) - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData(veryLongContent), - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - // Real formatNumberAbbreviated: 1000000 => "1M" - expect(screen.getByText(/1M/)).toBeInTheDocument() - }) - - it('should handle empty content', () => { - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData(''), - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - // Real formatNumberAbbreviated: 0 => "0" - // Find the element that contains character count info - expect(screen.getByText(/0 datasetPipeline/)).toBeInTheDocument() - }) - }) - - describe('useMemo for fileName', () => { - it('should extract file name when file exists', () => { - // When file exists, it should extract the name without extension - const file = createMockFile({ name: 'document.txt', extension: 'txt' }) - - render(<FilePreview {...defaultProps} file={file} />) - - expect(screen.getByText('document.txt')).toBeInTheDocument() - }) - - it('should memoize fileName based on file prop', () => { - const file = createMockFile({ name: 'test.pdf', extension: 'pdf' }) - - const { rerender } = render(<FilePreview {...defaultProps} file={file} />) - - // Same file should produce same result - rerender(<FilePreview {...defaultProps} file={file} />) - - expect(screen.getByText('test.pdf')).toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx deleted file mode 100644 index 2cfb14f42a..0000000000 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import type { CrawlResultItem } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import WebsitePreview from './web-preview' - -// Uses global react-i18next mock from web/vitest.setup.ts - -// Test data factory -const createMockCrawlResult = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => ({ - title: 'Test Website Title', - markdown: 'This is the **markdown** content of the website.', - description: 'Test description', - source_url: 'https://example.com/page', - ...overrides, -}) - -const defaultProps = { - currentWebsite: createMockCrawlResult(), - hidePreview: vi.fn(), -} - -describe('WebsitePreview', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render the component with website information', () => { - render(<WebsitePreview {...defaultProps} />) - - // i18n mock returns key by default - expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() - expect(screen.getByText('Test Website Title')).toBeInTheDocument() - }) - - it('should display the source URL', () => { - render(<WebsitePreview {...defaultProps} />) - - expect(screen.getByText('https://example.com/page')).toBeInTheDocument() - }) - - it('should render close button', () => { - render(<WebsitePreview {...defaultProps} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should render the markdown content', () => { - render(<WebsitePreview {...defaultProps} />) - - expect(screen.getByText('This is the **markdown** content of the website.')).toBeInTheDocument() - }) - }) - - describe('Character Count', () => { - it('should display character count for small content', () => { - const currentWebsite = createMockCrawlResult({ markdown: 'Hello' }) // 5 characters - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - // Real formatNumberAbbreviated returns "5" for numbers < 1000 - expect(screen.getByText(/5/)).toBeInTheDocument() - }) - - it('should format character count in thousands', () => { - const longContent = 'a'.repeat(2500) - const currentWebsite = createMockCrawlResult({ markdown: longContent }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - // Real formatNumberAbbreviated uses lowercase 'k': "2.5k" - expect(screen.getByText(/2\.5k/)).toBeInTheDocument() - }) - - it('should format character count in millions', () => { - const veryLongContent = 'a'.repeat(1500000) - const currentWebsite = createMockCrawlResult({ markdown: veryLongContent }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText(/1\.5M/)).toBeInTheDocument() - }) - - it('should show 0 characters for empty markdown', () => { - const currentWebsite = createMockCrawlResult({ markdown: '' }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText(/0/)).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call hidePreview when close button is clicked', () => { - const hidePreview = vi.fn() - - render(<WebsitePreview {...defaultProps} hidePreview={hidePreview} />) - - const closeButton = screen.getByRole('button') - fireEvent.click(closeButton) - - expect(hidePreview).toHaveBeenCalledTimes(1) - }) - }) - - describe('URL Display', () => { - it('should display long URLs', () => { - const longUrl = 'https://example.com/very/long/path/to/page/with/many/segments' - const currentWebsite = createMockCrawlResult({ source_url: longUrl }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - const urlElement = screen.getByTitle(longUrl) - expect(urlElement).toBeInTheDocument() - expect(urlElement).toHaveTextContent(longUrl) - }) - - it('should display URL with title attribute', () => { - const currentWebsite = createMockCrawlResult({ source_url: 'https://test.com' }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByTitle('https://test.com')).toBeInTheDocument() - }) - }) - - describe('Content Display', () => { - it('should display the markdown content in content area', () => { - const currentWebsite = createMockCrawlResult({ - markdown: 'Content with **bold** and *italic* text.', - }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText('Content with **bold** and *italic* text.')).toBeInTheDocument() - }) - - it('should handle multiline content', () => { - const multilineContent = 'Line 1\nLine 2\nLine 3' - const currentWebsite = createMockCrawlResult({ markdown: multilineContent }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - // Multiline content is rendered as-is - expect(screen.getByText((content) => { - return content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3') - })).toBeInTheDocument() - }) - - it('should handle special characters in content', () => { - const specialContent = '<script>alert("xss")</script> & < > " \'' - const currentWebsite = createMockCrawlResult({ markdown: specialContent }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText(specialContent)).toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle empty title', () => { - const currentWebsite = createMockCrawlResult({ title: '' }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should handle empty source URL', () => { - const currentWebsite = createMockCrawlResult({ source_url: '' }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should handle very long title', () => { - const longTitle = 'A'.repeat(500) - const currentWebsite = createMockCrawlResult({ title: longTitle }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText(longTitle)).toBeInTheDocument() - }) - - it('should handle unicode characters in content', () => { - const unicodeContent = 'äœ ć„œäž–ç•Œ 🌍 Ù…Ű±Ű­ŰšŰ§ こんにづは' - const currentWebsite = createMockCrawlResult({ markdown: unicodeContent }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText(unicodeContent)).toBeInTheDocument() - }) - - it('should handle URL with query parameters', () => { - const urlWithParams = 'https://example.com/page?query=test¶m=value' - const currentWebsite = createMockCrawlResult({ source_url: urlWithParams }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByTitle(urlWithParams)).toBeInTheDocument() - }) - - it('should handle URL with hash fragment', () => { - const urlWithHash = 'https://example.com/page#section-1' - const currentWebsite = createMockCrawlResult({ source_url: urlWithHash }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByTitle(urlWithHash)).toBeInTheDocument() - }) - }) - - describe('Styling', () => { - it('should apply container styles', () => { - const { container } = render(<WebsitePreview {...defaultProps} />) - - const mainContainer = container.firstChild as HTMLElement - expect(mainContainer).toHaveClass('flex', 'h-full', 'w-full', 'flex-col') - }) - }) - - describe('Multiple Renders', () => { - it('should update when currentWebsite changes', () => { - const website1 = createMockCrawlResult({ title: 'Website 1', markdown: 'Content 1' }) - const website2 = createMockCrawlResult({ title: 'Website 2', markdown: 'Content 2' }) - - const { rerender } = render(<WebsitePreview {...defaultProps} currentWebsite={website1} />) - - expect(screen.getByText('Website 1')).toBeInTheDocument() - expect(screen.getByText('Content 1')).toBeInTheDocument() - - rerender(<WebsitePreview {...defaultProps} currentWebsite={website2} />) - - expect(screen.getByText('Website 2')).toBeInTheDocument() - expect(screen.getByText('Content 2')).toBeInTheDocument() - }) - - it('should call new hidePreview when prop changes', () => { - const hidePreview1 = vi.fn() - const hidePreview2 = vi.fn() - - const { rerender } = render(<WebsitePreview {...defaultProps} hidePreview={hidePreview1} />) - - const closeButton = screen.getByRole('button') - fireEvent.click(closeButton) - expect(hidePreview1).toHaveBeenCalledTimes(1) - - rerender(<WebsitePreview {...defaultProps} hidePreview={hidePreview2} />) - - fireEvent.click(closeButton) - expect(hidePreview2).toHaveBeenCalledTimes(1) - expect(hidePreview1).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx new file mode 100644 index 0000000000..a4c5ec4938 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Actions from '../actions' + +describe('Actions', () => { + const defaultProps = { + onBack: vi.fn(), + onProcess: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verify both action buttons render with correct labels + describe('Rendering', () => { + it('should render back button and process button', () => { + render(<Actions {...defaultProps} />) + + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() + }) + }) + + // User interactions: clicking back and process buttons + describe('User Interactions', () => { + it('should call onBack when back button clicked', () => { + render(<Actions {...defaultProps} />) + + fireEvent.click(screen.getByText('datasetPipeline.operations.dataSource')) + + expect(defaultProps.onBack).toHaveBeenCalledOnce() + }) + + it('should call onProcess when process button clicked', () => { + render(<Actions {...defaultProps} />) + + fireEvent.click(screen.getByText('datasetPipeline.operations.saveAndProcess')) + + expect(defaultProps.onProcess).toHaveBeenCalledOnce() + }) + }) + + // Props: disabled state for the process button + describe('Props', () => { + it('should disable process button when runDisabled is true', () => { + render(<Actions {...defaultProps} runDisabled />) + + const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button') + expect(processButton).toBeDisabled() + }) + + it('should enable process button when runDisabled is false', () => { + render(<Actions {...defaultProps} runDisabled={false} />) + + const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button') + expect(processButton).not.toBeDisabled() + }) + + it('should enable process button when runDisabled is undefined', () => { + render(<Actions {...defaultProps} />) + + const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button') + expect(processButton).not.toBeDisabled() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx similarity index 83% rename from web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx index 6f47575b27..c82b5a8468 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx @@ -4,18 +4,14 @@ import * as React from 'react' import * as z from 'zod' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' import Toast from '@/app/components/base/toast' -import Actions from './actions' -import Form from './form' -import Header from './header' +import Actions from '../actions' +import Form from '../form' +import Header from '../header' -// ========================================== // Spy on Toast.notify for validation tests -// ========================================== const toastNotifySpy = vi.spyOn(Toast, 'notify') -// ========================================== // Test Data Factory Functions -// ========================================== /** * Creates mock configuration for testing @@ -56,9 +52,7 @@ const createFailingSchema = () => { } as unknown as z.ZodType } -// ========================================== // Actions Component Tests -// ========================================== describe('Actions', () => { const defaultActionsProps = { onBack: vi.fn(), @@ -69,137 +63,101 @@ describe('Actions', () => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} />) - // Assert expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() }) it('should render back button with arrow icon', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} />) - // Assert const backButton = screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i }) expect(backButton).toBeInTheDocument() expect(backButton.querySelector('svg')).toBeInTheDocument() }) it('should render process button', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeInTheDocument() }) it('should have correct container layout', () => { - // Arrange & Act const { container } = render(<Actions {...defaultActionsProps} />) - // Assert const mainContainer = container.querySelector('.flex.items-center.justify-between') expect(mainContainer).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('runDisabled prop', () => { it('should not disable process button when runDisabled is false', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} runDisabled={false} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).not.toBeDisabled() }) it('should disable process button when runDisabled is true', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} runDisabled={true} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeDisabled() }) it('should not disable process button when runDisabled is undefined', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} runDisabled={undefined} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).not.toBeDisabled() }) }) }) - // ========================================== // User Interactions Testing - // ========================================== describe('User Interactions', () => { it('should call onBack when back button is clicked', () => { - // Arrange const onBack = vi.fn() render(<Actions {...defaultActionsProps} onBack={onBack} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) - // Assert expect(onBack).toHaveBeenCalledTimes(1) }) it('should call onProcess when process button is clicked', () => { - // Arrange const onProcess = vi.fn() render(<Actions {...defaultActionsProps} onProcess={onProcess} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) - // Assert expect(onProcess).toHaveBeenCalledTimes(1) }) it('should not call onProcess when process button is disabled and clicked', () => { - // Arrange const onProcess = vi.fn() render(<Actions {...defaultActionsProps} onProcess={onProcess} runDisabled={true} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) - // Assert expect(onProcess).not.toHaveBeenCalled() }) }) - // ========================================== // Component Memoization Testing - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Assert expect(Actions.$$typeof).toBe(Symbol.for('react.memo')) }) }) }) -// ========================================== // Header Component Tests -// ========================================== describe('Header', () => { const defaultHeaderProps = { onReset: vi.fn(), @@ -211,73 +169,53 @@ describe('Header', () => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should render reset button', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} />) - // Assert expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() }) it('should render preview button with icon', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeInTheDocument() expect(previewButton.querySelector('svg')).toBeInTheDocument() }) it('should render title with correct text', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should have correct container layout', () => { - // Arrange & Act const { container } = render(<Header {...defaultHeaderProps} />) - // Assert const mainContainer = container.querySelector('.flex.items-center.gap-x-1') expect(mainContainer).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('resetDisabled prop', () => { it('should not disable reset button when resetDisabled is false', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} resetDisabled={false} />) - // Assert const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) expect(resetButton).not.toBeDisabled() }) it('should disable reset button when resetDisabled is true', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} resetDisabled={true} />) - // Assert const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) expect(resetButton).toBeDisabled() }) @@ -285,32 +223,25 @@ describe('Header', () => { describe('previewDisabled prop', () => { it('should not disable preview button when previewDisabled is false', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} previewDisabled={false} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).not.toBeDisabled() }) it('should disable preview button when previewDisabled is true', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} previewDisabled={true} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeDisabled() }) }) it('should handle onPreview being undefined', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} onPreview={undefined} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeInTheDocument() - // Click should not throw let didThrow = false try { fireEvent.click(previewButton) @@ -322,78 +253,57 @@ describe('Header', () => { }) }) - // ========================================== // User Interactions Testing - // ========================================== describe('User Interactions', () => { it('should call onReset when reset button is clicked', () => { - // Arrange const onReset = vi.fn() render(<Header {...defaultHeaderProps} onReset={onReset} />) - // Act fireEvent.click(screen.getByRole('button', { name: /common.operation.reset/i })) - // Assert expect(onReset).toHaveBeenCalledTimes(1) }) it('should not call onReset when reset button is disabled and clicked', () => { - // Arrange const onReset = vi.fn() render(<Header {...defaultHeaderProps} onReset={onReset} resetDisabled={true} />) - // Act fireEvent.click(screen.getByRole('button', { name: /common.operation.reset/i })) - // Assert expect(onReset).not.toHaveBeenCalled() }) it('should call onPreview when preview button is clicked', () => { - // Arrange const onPreview = vi.fn() render(<Header {...defaultHeaderProps} onPreview={onPreview} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) - // Assert expect(onPreview).toHaveBeenCalledTimes(1) }) it('should not call onPreview when preview button is disabled and clicked', () => { - // Arrange const onPreview = vi.fn() render(<Header {...defaultHeaderProps} onPreview={onPreview} previewDisabled={true} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) - // Assert expect(onPreview).not.toHaveBeenCalled() }) }) - // ========================================== // Component Memoization Testing - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Assert expect(Header.$$typeof).toBe(Symbol.for('react.memo')) }) }) - // ========================================== // Edge Cases Testing - // ========================================== describe('Edge Cases', () => { it('should handle both buttons disabled', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} resetDisabled={true} previewDisabled={true} />) - // Assert const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(resetButton).toBeDisabled() @@ -401,10 +311,8 @@ describe('Header', () => { }) it('should handle both buttons enabled', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} resetDisabled={false} previewDisabled={false} />) - // Assert const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(resetButton).not.toBeDisabled() @@ -413,9 +321,7 @@ describe('Header', () => { }) }) -// ========================================== // Form Component Tests -// ========================================== describe('Form', () => { const defaultFormProps = { initialData: { field1: '' }, @@ -432,66 +338,48 @@ describe('Form', () => { toastNotifySpy.mockClear() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<Form {...defaultFormProps} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should render form element', () => { - // Arrange & Act const { container } = render(<Form {...defaultFormProps} />) - // Assert const form = container.querySelector('form') expect(form).toBeInTheDocument() }) it('should render Header component', () => { - // Arrange & Act render(<Form {...defaultFormProps} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })).toBeInTheDocument() }) it('should have correct form structure', () => { - // Arrange & Act const { container } = render(<Form {...defaultFormProps} />) - // Assert const form = container.querySelector('form.flex.w-full.flex-col') expect(form).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('isRunning prop', () => { it('should disable preview button when isRunning is true', () => { - // Arrange & Act render(<Form {...defaultFormProps} isRunning={true} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeDisabled() }) it('should not disable preview button when isRunning is false', () => { - // Arrange & Act render(<Form {...defaultFormProps} isRunning={false} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).not.toBeDisabled() }) @@ -499,7 +387,6 @@ describe('Form', () => { describe('configurations prop', () => { it('should render empty when configurations is empty', () => { - // Arrange & Act const { container } = render(<Form {...defaultFormProps} configurations={[]} />) // Assert - the fields container should have no field children @@ -508,17 +395,14 @@ describe('Form', () => { }) it('should render all configurations', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'var1', label: 'Variable 1' }), createMockConfiguration({ variable: 'var2', label: 'Variable 2' }), createMockConfiguration({ variable: 'var3', label: 'Variable 3' }), ] - // Act render(<Form {...defaultFormProps} configurations={configurations} initialData={{ var1: '', var2: '', var3: '' }} />) - // Assert expect(screen.getByText('Variable 1')).toBeInTheDocument() expect(screen.getByText('Variable 2')).toBeInTheDocument() expect(screen.getByText('Variable 3')).toBeInTheDocument() @@ -526,24 +410,18 @@ describe('Form', () => { }) it('should expose submit method via ref', () => { - // Arrange const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> - // Act render(<Form {...defaultFormProps} ref={mockRef} />) - // Assert expect(mockRef.current).not.toBeNull() expect(typeof mockRef.current?.submit).toBe('function') }) }) - // ========================================== // Ref Submit Testing - // ========================================== describe('Ref Submit', () => { it('should call onSubmit when ref.submit() is called', async () => { - // Arrange const onSubmit = vi.fn() const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> render(<Form {...defaultFormProps} ref={mockRef} onSubmit={onSubmit} />) @@ -551,14 +429,12 @@ describe('Form', () => { // Act - call submit via ref mockRef.current?.submit() - // Assert await waitFor(() => { expect(onSubmit).toHaveBeenCalled() }) }) it('should trigger form validation when ref.submit() is called', async () => { - // Arrange const failingSchema = createFailingSchema() const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> render(<Form {...defaultFormProps} ref={mockRef} schema={failingSchema} />) @@ -576,53 +452,40 @@ describe('Form', () => { }) }) - // ========================================== // User Interactions Testing - // ========================================== describe('User Interactions', () => { it('should call onPreview when preview button is clicked', () => { - // Arrange const onPreview = vi.fn() render(<Form {...defaultFormProps} onPreview={onPreview} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) - // Assert expect(onPreview).toHaveBeenCalledTimes(1) }) it('should handle form submission via form element', async () => { - // Arrange const onSubmit = vi.fn() const { container } = render(<Form {...defaultFormProps} onSubmit={onSubmit} />) const form = container.querySelector('form')! - // Act fireEvent.submit(form) - // Assert await waitFor(() => { expect(onSubmit).toHaveBeenCalled() }) }) }) - // ========================================== // Form State Testing - // ========================================== describe('Form State', () => { it('should disable reset button initially when form is not dirty', () => { - // Arrange & Act render(<Form {...defaultFormProps} />) - // Assert const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) expect(resetButton).toBeDisabled() }) it('should enable reset button when form becomes dirty', async () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field1', label: 'Field 1' }), ] @@ -633,7 +496,6 @@ describe('Form', () => { const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new value' } }) - // Assert await waitFor(() => { const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) expect(resetButton).not.toBeDisabled() @@ -641,7 +503,6 @@ describe('Form', () => { }) it('should reset form to initial values when reset button is clicked', async () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field1', label: 'Field 1' }), ] @@ -659,7 +520,6 @@ describe('Form', () => { expect(resetButton).not.toBeDisabled() }) - // Click reset button const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) fireEvent.click(resetButton) @@ -670,7 +530,6 @@ describe('Form', () => { }) it('should call form.reset when handleReset is triggered', async () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field1', label: 'Field 1' }), ] @@ -697,20 +556,15 @@ describe('Form', () => { }) }) - // ========================================== // Validation Testing - // ========================================== describe('Validation', () => { it('should show toast notification on validation error', async () => { - // Arrange const failingSchema = createFailingSchema() const { container } = render(<Form {...defaultFormProps} schema={failingSchema} />) - // Act const form = container.querySelector('form')! fireEvent.submit(form) - // Assert await waitFor(() => { expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'error', @@ -720,12 +574,10 @@ describe('Form', () => { }) it('should not call onSubmit when validation fails', async () => { - // Arrange const onSubmit = vi.fn() const failingSchema = createFailingSchema() const { container } = render(<Form {...defaultFormProps} schema={failingSchema} onSubmit={onSubmit} />) - // Act const form = container.querySelector('form')! fireEvent.submit(form) @@ -737,93 +589,71 @@ describe('Form', () => { }) it('should call onSubmit when validation passes', async () => { - // Arrange const onSubmit = vi.fn() const passingSchema = createMockSchema() const { container } = render(<Form {...defaultFormProps} schema={passingSchema} onSubmit={onSubmit} />) - // Act const form = container.querySelector('form')! fireEvent.submit(form) - // Assert await waitFor(() => { expect(onSubmit).toHaveBeenCalled() }) }) }) - // ========================================== // Edge Cases Testing - // ========================================== describe('Edge Cases', () => { it('should handle empty initialData', () => { - // Arrange & Act render(<Form {...defaultFormProps} initialData={{}} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should handle configurations with different field types', () => { - // Arrange const configurations = [ createMockConfiguration({ type: BaseFieldType.textInput, variable: 'text', label: 'Text Field' }), createMockConfiguration({ type: BaseFieldType.numberInput, variable: 'number', label: 'Number Field' }), ] - // Act render(<Form {...defaultFormProps} configurations={configurations} initialData={{ text: '', number: 0 }} />) - // Assert expect(screen.getByText('Text Field')).toBeInTheDocument() expect(screen.getByText('Number Field')).toBeInTheDocument() }) it('should handle null ref', () => { - // Arrange & Act render(<Form {...defaultFormProps} ref={{ current: null }} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) }) - // ========================================== // Configuration Variations Testing - // ========================================== describe('Configuration Variations', () => { it('should render configuration with label', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field1', label: 'Custom Label' }), ] - // Act render(<Form {...defaultFormProps} configurations={configurations} />) - // Assert expect(screen.getByText('Custom Label')).toBeInTheDocument() }) it('should render required configuration', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field1', label: 'Required Field', required: true }), ] - // Act render(<Form {...defaultFormProps} configurations={configurations} />) - // Assert expect(screen.getByText('Required Field')).toBeInTheDocument() }) }) }) -// ========================================== // Integration Tests (Cross-component) -// ========================================== describe('Process Documents Components Integration', () => { beforeEach(() => { vi.clearAllMocks() @@ -841,19 +671,15 @@ describe('Process Documents Components Integration', () => { } it('should render Header within Form', () => { - // Arrange & Act render(<Form {...defaultFormProps} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() }) it('should pass isRunning to Header for previewDisabled', () => { - // Arrange & Act render(<Form {...defaultFormProps} isRunning={true} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeDisabled() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx new file mode 100644 index 0000000000..7ce3a6396e --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Header from '../header' + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, disabled, variant }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, variant: string }) => ( + <button data-testid={`btn-${variant}`} onClick={onClick} disabled={disabled}> + {children} + </button> + ), +})) + +describe('Header', () => { + const defaultProps = { + onReset: vi.fn(), + resetDisabled: false, + previewDisabled: false, + onPreview: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render chunk settings title', () => { + render(<Header {...defaultProps} />) + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + + it('should render reset and preview buttons', () => { + render(<Header {...defaultProps} />) + expect(screen.getByTestId('btn-ghost')).toBeInTheDocument() + expect(screen.getByTestId('btn-secondary-accent')).toBeInTheDocument() + }) + + it('should call onReset when reset clicked', () => { + render(<Header {...defaultProps} />) + fireEvent.click(screen.getByTestId('btn-ghost')) + expect(defaultProps.onReset).toHaveBeenCalled() + }) + + it('should call onPreview when preview clicked', () => { + render(<Header {...defaultProps} />) + fireEvent.click(screen.getByTestId('btn-secondary-accent')) + expect(defaultProps.onPreview).toHaveBeenCalled() + }) + + it('should disable reset button when resetDisabled is true', () => { + render(<Header {...defaultProps} resetDisabled={true} />) + expect(screen.getByTestId('btn-ghost')).toBeDisabled() + }) + + it('should disable preview button when previewDisabled is true', () => { + render(<Header {...defaultProps} previewDisabled={true} />) + expect(screen.getByTestId('btn-secondary-accent')).toBeDisabled() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..440c978196 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts @@ -0,0 +1,52 @@ +import type { PipelineProcessingParamsRequest } from '@/models/pipeline' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useInputVariables } from '../hooks' + +const mockUseDatasetDetailContextWithSelector = vi.fn() +const mockUsePublishedPipelineProcessingParams = vi.fn() + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (value: unknown) => unknown) => mockUseDatasetDetailContextWithSelector(selector), +})) +vi.mock('@/service/use-pipeline', () => ({ + usePublishedPipelineProcessingParams: (params: PipelineProcessingParamsRequest) => mockUsePublishedPipelineProcessingParams(params), +})) + +describe('useInputVariables', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseDatasetDetailContextWithSelector.mockReturnValue('pipeline-123') + mockUsePublishedPipelineProcessingParams.mockReturnValue({ + data: { inputs: [{ name: 'query', type: 'string' }] }, + isFetching: false, + }) + }) + + it('should return paramsConfig and isFetchingParams', () => { + const { result } = renderHook(() => useInputVariables('node-1')) + + expect(result.current.paramsConfig).toEqual({ inputs: [{ name: 'query', type: 'string' }] }) + expect(result.current.isFetchingParams).toBe(false) + }) + + it('should call usePublishedPipelineProcessingParams with pipeline_id and node_id', () => { + renderHook(() => useInputVariables('node-1')) + + expect(mockUsePublishedPipelineProcessingParams).toHaveBeenCalledWith({ + pipeline_id: 'pipeline-123', + node_id: 'node-1', + }) + }) + + it('should return isFetchingParams true when loading', () => { + mockUsePublishedPipelineProcessingParams.mockReturnValue({ + data: undefined, + isFetching: true, + }) + + const { result } = renderHook(() => useInputVariables('node-1')) + expect(result.current.isFetchingParams).toBe(true) + expect(result.current.paramsConfig).toBeUndefined() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/index.spec.tsx similarity index 85% rename from web/app/components/datasets/documents/create-from-pipeline/process-documents/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/index.spec.tsx index 318a6c2cba..6fe6957134 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/index.spec.tsx @@ -3,17 +3,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields' -import { useInputVariables } from './hooks' -import ProcessDocuments from './index' - -// ========================================== -// Mock External Dependencies -// ========================================== +import { useInputVariables } from '../hooks' +import ProcessDocuments from '../index' // Mock useInputVariables hook let mockIsFetchingParams = false let mockParamsConfig: { variables: unknown[] } | undefined = { variables: [] } -vi.mock('./hooks', () => ({ +vi.mock('../hooks', () => ({ useInputVariables: vi.fn(() => ({ isFetchingParams: mockIsFetchingParams, paramsConfig: mockParamsConfig, @@ -30,9 +26,7 @@ vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ useConfigurations: vi.fn(() => mockConfigurations), })) -// ========================================== // Test Data Factory Functions -// ========================================== /** * Creates mock configuration for testing @@ -64,10 +58,6 @@ const createDefaultProps = (overrides: Partial<React.ComponentProps<typeof Proce ...overrides, }) -// ========================================== -// Test Suite -// ========================================== - describe('ProcessDocuments', () => { beforeEach(() => { vi.clearAllMocks() @@ -78,16 +68,11 @@ describe('ProcessDocuments', () => { mockConfigurations = [] }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { // Tests basic rendering functionality it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) // Assert - check for Header title from Form component @@ -95,10 +80,8 @@ describe('ProcessDocuments', () => { }) it('should render Form and Actions components', () => { - // Arrange const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) // Assert - check for elements from both components @@ -108,80 +91,59 @@ describe('ProcessDocuments', () => { }) it('should render with correct container structure', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<ProcessDocuments {...props} />) - // Assert const mainContainer = container.querySelector('.flex.flex-col.gap-y-4.pt-4') expect(mainContainer).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('dataSourceNodeId prop', () => { it('should pass dataSourceNodeId to useInputVariables hook', () => { - // Arrange const props = createDefaultProps({ dataSourceNodeId: 'custom-node-id' }) - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInputVariables)).toHaveBeenCalledWith('custom-node-id') }) it('should handle empty dataSourceNodeId', () => { - // Arrange const props = createDefaultProps({ dataSourceNodeId: '' }) - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) }) describe('isRunning prop', () => { it('should disable preview button when isRunning is true', () => { - // Arrange const props = createDefaultProps({ isRunning: true }) - // Act render(<ProcessDocuments {...props} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeDisabled() }) it('should not disable preview button when isRunning is false', () => { - // Arrange const props = createDefaultProps({ isRunning: false }) - // Act render(<ProcessDocuments {...props} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).not.toBeDisabled() }) it('should disable process button in Actions when isRunning is true', () => { - // Arrange mockIsFetchingParams = false const props = createDefaultProps({ isRunning: true }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeDisabled() }) @@ -189,200 +151,153 @@ describe('ProcessDocuments', () => { describe('ref prop', () => { it('should expose submit method via ref', () => { - // Arrange const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> const props = createDefaultProps({ ref: mockRef }) - // Act render(<ProcessDocuments {...props} />) - // Assert expect(mockRef.current).not.toBeNull() expect(typeof mockRef.current?.submit).toBe('function') }) }) }) - // ========================================== // User Interactions Testing - // ========================================== describe('User Interactions', () => { it('should call onProcess when Actions process button is clicked', () => { - // Arrange const onProcess = vi.fn() const props = createDefaultProps({ onProcess }) render(<ProcessDocuments {...props} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) - // Assert expect(onProcess).toHaveBeenCalledTimes(1) }) it('should call onBack when Actions back button is clicked', () => { - // Arrange const onBack = vi.fn() const props = createDefaultProps({ onBack }) render(<ProcessDocuments {...props} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) - // Assert expect(onBack).toHaveBeenCalledTimes(1) }) it('should call onPreview when preview button is clicked', () => { - // Arrange const onPreview = vi.fn() const props = createDefaultProps({ onPreview }) render(<ProcessDocuments {...props} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) - // Assert expect(onPreview).toHaveBeenCalledTimes(1) }) it('should call onSubmit when form is submitted', async () => { - // Arrange const onSubmit = vi.fn() const props = createDefaultProps({ onSubmit }) const { container } = render(<ProcessDocuments {...props} />) - // Act const form = container.querySelector('form')! fireEvent.submit(form) - // Assert await waitFor(() => { expect(onSubmit).toHaveBeenCalled() }) }) }) - // ========================================== // Hook Integration Tests - // ========================================== describe('Hook Integration', () => { it('should pass variables from useInputVariables to useInitialData', () => { - // Arrange const mockVariables = [{ variable: 'testVar', type: 'text', label: 'Test' }] mockParamsConfig = { variables: mockVariables } const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInitialData)).toHaveBeenCalledWith(mockVariables) }) it('should pass variables from useInputVariables to useConfigurations', () => { - // Arrange const mockVariables = [{ variable: 'testVar', type: 'text', label: 'Test' }] mockParamsConfig = { variables: mockVariables } const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useConfigurations)).toHaveBeenCalledWith(mockVariables) }) it('should use empty array when paramsConfig.variables is undefined', () => { - // Arrange mockParamsConfig = { variables: undefined as unknown as unknown[] } const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInitialData)).toHaveBeenCalledWith([]) expect(vi.mocked(useConfigurations)).toHaveBeenCalledWith([]) }) it('should use empty array when paramsConfig is undefined', () => { - // Arrange mockParamsConfig = undefined const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInitialData)).toHaveBeenCalledWith([]) expect(vi.mocked(useConfigurations)).toHaveBeenCalledWith([]) }) }) - // ========================================== // Actions runDisabled Testing - // ========================================== describe('Actions runDisabled', () => { it('should disable process button when isFetchingParams is true', () => { - // Arrange mockIsFetchingParams = true const props = createDefaultProps({ isRunning: false }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeDisabled() }) it('should disable process button when isRunning is true', () => { - // Arrange mockIsFetchingParams = false const props = createDefaultProps({ isRunning: true }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeDisabled() }) it('should enable process button when both isFetchingParams and isRunning are false', () => { - // Arrange mockIsFetchingParams = false const props = createDefaultProps({ isRunning: false }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).not.toBeDisabled() }) it('should disable process button when both isFetchingParams and isRunning are true', () => { - // Arrange mockIsFetchingParams = true const props = createDefaultProps({ isRunning: true }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeDisabled() }) }) - // ========================================== // Component Memoization Testing - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - verify component has memo wrapper @@ -390,86 +305,65 @@ describe('ProcessDocuments', () => { }) it('should render correctly after rerender with same props', () => { - // Arrange const props = createDefaultProps() - // Act const { rerender } = render(<ProcessDocuments {...props} />) rerender(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should update when dataSourceNodeId prop changes', () => { - // Arrange const props = createDefaultProps({ dataSourceNodeId: 'node-1' }) - // Act const { rerender } = render(<ProcessDocuments {...props} />) expect(vi.mocked(useInputVariables)).toHaveBeenLastCalledWith('node-1') rerender(<ProcessDocuments {...props} dataSourceNodeId="node-2" />) - // Assert expect(vi.mocked(useInputVariables)).toHaveBeenLastCalledWith('node-2') }) }) - // ========================================== // Edge Cases Testing - // ========================================== describe('Edge Cases', () => { it('should handle undefined paramsConfig gracefully', () => { - // Arrange mockParamsConfig = undefined const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should handle empty variables array', () => { - // Arrange mockParamsConfig = { variables: [] } mockConfigurations = [] const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should handle special characters in dataSourceNodeId', () => { - // Arrange const props = createDefaultProps({ dataSourceNodeId: 'node-id-with-special_chars:123' }) - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInputVariables)).toHaveBeenCalledWith('node-id-with-special_chars:123') }) it('should handle long dataSourceNodeId', () => { - // Arrange const longId = 'a'.repeat(1000) const props = createDefaultProps({ dataSourceNodeId: longId }) - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInputVariables)).toHaveBeenCalledWith(longId) }) it('should handle multiple callbacks without interference', () => { - // Arrange const onProcess = vi.fn() const onBack = vi.fn() const onPreview = vi.fn() @@ -477,21 +371,17 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) - // Assert expect(onProcess).toHaveBeenCalledTimes(1) expect(onBack).toHaveBeenCalledTimes(1) expect(onPreview).toHaveBeenCalledTimes(1) }) }) - // ========================================== // runDisabled Logic Testing (with test.each) - // ========================================== describe('runDisabled Logic', () => { const runDisabledTestCases = [ { isFetchingParams: false, isRunning: false, expectedDisabled: false }, @@ -503,14 +393,11 @@ describe('ProcessDocuments', () => { it.each(runDisabledTestCases)( 'should set process button disabled=$expectedDisabled when isFetchingParams=$isFetchingParams and isRunning=$isRunning', ({ isFetchingParams, isRunning, expectedDisabled }) => { - // Arrange mockIsFetchingParams = isFetchingParams const props = createDefaultProps({ isRunning }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) if (expectedDisabled) expect(processButton).toBeDisabled() @@ -520,12 +407,9 @@ describe('ProcessDocuments', () => { ) }) - // ========================================== // Configuration Rendering Tests - // ========================================== describe('Configuration Rendering', () => { it('should render configurations as form fields', () => { - // Arrange mockConfigurations = [ createMockConfiguration({ variable: 'var1', label: 'Variable 1' }), createMockConfiguration({ variable: 'var2', label: 'Variable 2' }), @@ -533,16 +417,13 @@ describe('ProcessDocuments', () => { mockInitialData = { var1: '', var2: '' } const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('Variable 1')).toBeInTheDocument() expect(screen.getByText('Variable 2')).toBeInTheDocument() }) it('should handle configurations with different field types', () => { - // Arrange mockConfigurations = [ createMockConfiguration({ type: BaseFieldType.textInput, variable: 'textVar', label: 'Text Field' }), createMockConfiguration({ type: BaseFieldType.numberInput, variable: 'numberVar', label: 'Number Field' }), @@ -550,21 +431,16 @@ describe('ProcessDocuments', () => { mockInitialData = { textVar: '', numberVar: 0 } const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('Text Field')).toBeInTheDocument() expect(screen.getByText('Number Field')).toBeInTheDocument() }) }) - // ========================================== // Full Integration Props Testing - // ========================================== describe('Full Prop Integration', () => { it('should render correctly with all props provided', () => { - // Arrange const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> mockIsFetchingParams = false mockParamsConfig = { variables: [{ variable: 'testVar', type: 'text', label: 'Test' }] } @@ -581,10 +457,8 @@ describe('ProcessDocuments', () => { onBack: vi.fn(), } - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/processing/__tests__/index.spec.tsx index 554af2a546..688d26f245 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/__tests__/index.spec.tsx @@ -3,11 +3,7 @@ import type { InitialDocumentDetail } from '@/models/pipeline' import { render, screen } from '@testing-library/react' import * as React from 'react' import { DatasourceType } from '@/models/pipeline' -import Processing from './index' - -// ========================================== -// Mock External Dependencies -// ========================================== +import Processing from '../index' // Mock useDocLink - returns a function that generates doc URLs // Strips leading slash from path to match actual implementation behavior @@ -33,7 +29,7 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock the EmbeddingProcess component to track props let embeddingProcessProps: Record<string, unknown> = {} -vi.mock('./embedding-process', () => ({ +vi.mock('../embedding-process', () => ({ default: (props: Record<string, unknown>) => { embeddingProcessProps = props return ( @@ -48,9 +44,7 @@ vi.mock('./embedding-process', () => ({ }, })) -// ========================================== // Test Data Factory Functions -// ========================================== /** * Creates a mock InitialDocumentDetail for testing @@ -80,10 +74,6 @@ const createMockDocuments = (count: number): InitialDocumentDetail[] => position: index, })) -// ========================================== -// Test Suite -// ========================================== - describe('Processing', () => { beforeEach(() => { vi.clearAllMocks() @@ -98,47 +88,36 @@ describe('Processing', () => { } }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { // Tests basic rendering functionality it('should render without crashing', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(2), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() }) it('should render the EmbeddingProcess component', () => { - // Arrange const props = { batchId: 'batch-456', documents: createMockDocuments(3), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() }) it('should render the side tip section with correct content', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) // Assert - verify translation keys are rendered @@ -148,16 +127,13 @@ describe('Processing', () => { }) it('should render the documentation link with correct attributes', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert const link = screen.getByRole('link', { name: 'datasetPipeline.addDocuments.stepThree.learnMore' }) expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/knowledge-pipeline/authorize-data-source') expect(link).toHaveAttribute('target', '_blank') @@ -165,13 +141,11 @@ describe('Processing', () => { }) it('should render the book icon in the side tip', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act const { container } = render(<Processing {...props} />) // Assert - check for icon container with shadow styling @@ -180,45 +154,35 @@ describe('Processing', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { // Tests that props are correctly passed to child components it('should pass batchId to EmbeddingProcess', () => { - // Arrange const testBatchId = 'test-batch-id-789' const props = { batchId: testBatchId, documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-batch-id')).toHaveTextContent(testBatchId) expect(embeddingProcessProps.batchId).toBe(testBatchId) }) it('should pass documents to EmbeddingProcess', () => { - // Arrange const documents = createMockDocuments(5) const props = { batchId: 'batch-123', documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('5') expect(embeddingProcessProps.documents).toEqual(documents) }) it('should pass datasetId from context to EmbeddingProcess', () => { - // Arrange mockDataset = { id: 'context-dataset-id', indexing_technique: 'high_quality', @@ -229,16 +193,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('context-dataset-id') expect(embeddingProcessProps.datasetId).toBe('context-dataset-id') }) it('should pass indexingType from context to EmbeddingProcess', () => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: 'economy', @@ -249,16 +210,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy') expect(embeddingProcessProps.indexingType).toBe('economy') }) it('should pass retrievalMethod from context to EmbeddingProcess', () => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: 'high_quality', @@ -269,16 +227,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('keyword_search') expect(embeddingProcessProps.retrievalMethod).toBe('keyword_search') }) it('should handle different document types', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-local', @@ -301,63 +256,49 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3') expect(embeddingProcessProps.documents).toEqual(documents) }) }) - // ========================================== - // Edge Cases - // ========================================== describe('Edge Cases', () => { // Tests for boundary conditions and unusual inputs it('should handle empty documents array', () => { - // Arrange const props = { batchId: 'batch-123', documents: [], } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0') expect(embeddingProcessProps.documents).toEqual([]) }) it('should handle empty batchId', () => { - // Arrange const props = { batchId: '', documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('') }) it('should handle undefined dataset from context', () => { - // Arrange mockDataset = undefined const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.datasetId).toBeUndefined() expect(embeddingProcessProps.indexingType).toBeUndefined() @@ -365,7 +306,6 @@ describe('Processing', () => { }) it('should handle dataset with undefined id', () => { - // Arrange mockDataset = { id: undefined, indexing_technique: 'high_quality', @@ -376,16 +316,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.datasetId).toBeUndefined() }) it('should handle dataset with undefined indexing_technique', () => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: undefined, @@ -396,16 +333,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.indexingType).toBeUndefined() }) it('should handle dataset with undefined retrieval_model_dict', () => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: 'high_quality', @@ -416,16 +350,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.retrievalMethod).toBeUndefined() }) it('should handle dataset with empty retrieval_model_dict', () => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: 'high_quality', @@ -436,31 +367,25 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.retrievalMethod).toBeUndefined() }) it('should handle large number of documents', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(100), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('100') }) it('should handle documents with error status', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-error', @@ -474,16 +399,13 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.documents).toEqual(documents) }) it('should handle documents with special characters in names', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-special', @@ -495,36 +417,28 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.documents).toEqual(documents) }) it('should handle batchId with special characters', () => { - // Arrange const props = { batchId: 'batch-123-abc_xyz:456', documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('batch-123-abc_xyz:456') }) }) - // ========================================== // Context Integration Tests - // ========================================== describe('Context Integration', () => { // Tests for proper context usage it('should correctly use context selectors for all dataset properties', () => { - // Arrange mockDataset = { id: 'full-dataset-id', indexing_technique: 'high_quality', @@ -535,10 +449,8 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(embeddingProcessProps.datasetId).toBe('full-dataset-id') expect(embeddingProcessProps.indexingType).toBe('high_quality') expect(embeddingProcessProps.retrievalMethod).toBe('hybrid_search') @@ -556,7 +468,6 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act const { rerender } = render(<Processing {...props} />) // Assert economy indexing @@ -577,19 +488,14 @@ describe('Processing', () => { }) }) - // ========================================== - // Layout Tests - // ========================================== describe('Layout', () => { // Tests for proper layout and structure it('should render with correct layout structure', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act const { container } = render(<Processing {...props} />) // Assert - Check for flex layout with proper widths @@ -606,13 +512,11 @@ describe('Processing', () => { }) it('should render side tip card with correct styling', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act const { container } = render(<Processing {...props} />) // Assert - Check for card container with rounded corners and background @@ -621,28 +525,22 @@ describe('Processing', () => { }) it('should constrain max-width for EmbeddingProcess container', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act const { container } = render(<Processing {...props} />) - // Assert const maxWidthContainer = container.querySelector('.max-w-\\[640px\\]') expect(maxWidthContainer).toBeInTheDocument() }) }) - // ========================================== // Document Variations Tests - // ========================================== describe('Document Variations', () => { // Tests for different document configurations it('should handle documents with all indexing statuses', () => { - // Arrange const statuses: DocumentIndexingStatus[] = [ 'waiting', 'parsing', @@ -666,16 +564,13 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent(String(statuses.length)) expect(embeddingProcessProps.documents).toEqual(documents) }) it('should handle documents with enabled and disabled states', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-enabled', enable: true }), createMockDocument({ id: 'doc-disabled', enable: false }), @@ -685,16 +580,13 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('2') expect(embeddingProcessProps.documents).toEqual(documents) }) it('should handle documents from online drive source', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-drive', @@ -708,16 +600,13 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.documents).toEqual(documents) }) it('should handle documents with complex data_source_info', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-notion', @@ -735,23 +624,18 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(embeddingProcessProps.documents).toEqual(documents) }) }) - // ========================================== // Retrieval Method Variations - // ========================================== describe('Retrieval Method Variations', () => { // Tests for different retrieval methods const retrievalMethods = ['semantic_search', 'keyword_search', 'hybrid_search', 'full_text_search'] it.each(retrievalMethods)('should handle %s retrieval method', (method) => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: 'high_quality', @@ -762,23 +646,18 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(embeddingProcessProps.retrievalMethod).toBe(method) }) }) - // ========================================== // Indexing Technique Variations - // ========================================== describe('Indexing Technique Variations', () => { // Tests for different indexing techniques const indexingTechniques = ['high_quality', 'economy'] it.each(indexingTechniques)('should handle %s indexing technique', (technique) => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: technique, @@ -789,10 +668,8 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(embeddingProcessProps.indexingType).toBe(technique) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index 81e97a79a1..aa107b8635 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -7,13 +7,8 @@ import { Plan } from '@/app/components/billing/type' import { IndexingType } from '@/app/components/datasets/create/step-two' import { DatasourceType } from '@/models/pipeline' import { RETRIEVE_METHOD } from '@/types/app' -import EmbeddingProcess from './index' +import EmbeddingProcess from '../index' -// ========================================== -// Mock External Dependencies -// ========================================== - -// Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -64,9 +59,7 @@ vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets', })) -// ========================================== // Test Data Factory Functions -// ========================================== /** * Creates a mock InitialDocumentDetail for testing @@ -122,10 +115,6 @@ const createDefaultProps = (overrides: Partial<{ ...overrides, }) -// ========================================== -// Test Suite -// ========================================== - describe('EmbeddingProcess', () => { beforeEach(() => { vi.clearAllMocks() @@ -151,30 +140,22 @@ describe('EmbeddingProcess', () => { vi.useRealTimers() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { // Tests basic rendering functionality it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.getByTestId('rule-detail')).toBeInTheDocument() }) it('should render RuleDetail component with correct props', () => { - // Arrange const props = createDefaultProps({ indexingType: IndexingType.ECONOMICAL, retrievalMethod: RETRIEVE_METHOD.fullText, }) - // Act render(<EmbeddingProcess {...props} />) // Assert - RuleDetail renders FieldInfo components with translated text @@ -183,13 +164,10 @@ describe('EmbeddingProcess', () => { }) it('should render API reference link with correct URL', () => { - // Arrange const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert const apiLink = screen.getByRole('link', { name: /access the api/i }) expect(apiLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') expect(apiLink).toHaveAttribute('target', '_blank') @@ -197,231 +175,185 @@ describe('EmbeddingProcess', () => { }) it('should render navigation button', () => { - // Arrange const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.getByText('datasetCreation.stepThree.navTo')).toBeInTheDocument() }) }) - // ========================================== // Billing/Upgrade Banner Tests - // ========================================== describe('Billing and Upgrade Banner', () => { // Tests for billing-related UI it('should not show upgrade banner when billing is disabled', () => { - // Arrange mockEnableBilling = false const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade')).not.toBeInTheDocument() }) it('should show upgrade banner when billing is enabled and plan is not team', () => { - // Arrange mockEnableBilling = true mockPlanType = Plan.sandbox const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument() }) it('should not show upgrade banner when plan is team', () => { - // Arrange mockEnableBilling = true mockPlanType = Plan.team const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade')).not.toBeInTheDocument() }) it('should show upgrade banner for professional plan', () => { - // Arrange mockEnableBilling = true mockPlanType = Plan.professional const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument() }) }) - // ========================================== // Status Display Tests - // ========================================== describe('Status Display', () => { // Tests for embedding status display it('should show waiting status when all documents are waiting', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'waiting' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.waiting')).toBeInTheDocument() }) it('should show processing status when any document is indexing', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() }) it('should show processing status when any document is splitting', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'splitting' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() }) it('should show processing status when any document is parsing', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'parsing' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() }) it('should show processing status when any document is cleaning', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'cleaning' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() }) it('should show completed status when all documents are completed', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'completed' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() }) it('should show completed status when all documents have error status', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'error', error: 'Processing failed' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() }) it('should show completed status when all documents are paused', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'paused' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() }) }) - // ========================================== // Progress Bar Tests - // ========================================== describe('Progress Display', () => { // Tests for progress bar rendering it('should show progress percentage for embedding documents', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -433,18 +365,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('50%')).toBeInTheDocument() }) it('should cap progress at 100%', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -456,18 +385,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('100%')).toBeInTheDocument() }) it('should show 0% when total_segments is 0', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -479,18 +405,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('0%')).toBeInTheDocument() }) it('should not show progress for completed documents', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -502,27 +425,21 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.queryByText('100%')).not.toBeInTheDocument() }) }) - // ========================================== // Polling Logic Tests - // ========================================== describe('Polling Logic', () => { // Tests for API polling behavior it('should start polling on mount', async () => { - // Arrange const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) // Assert - verify fetch was called at least once @@ -532,7 +449,6 @@ describe('EmbeddingProcess', () => { }) it('should continue polling while documents are processing', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), @@ -540,7 +456,6 @@ describe('EmbeddingProcess', () => { const props = createDefaultProps({ documents: [doc1] }) const initialCallCount = mockFetchIndexingStatus.mock.calls.length - // Act render(<EmbeddingProcess {...props} />) // Wait for initial fetch @@ -560,14 +475,12 @@ describe('EmbeddingProcess', () => { }) it('should stop polling when all documents are completed', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'completed' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) // Wait for initial fetch and state update @@ -586,14 +499,12 @@ describe('EmbeddingProcess', () => { }) it('should stop polling when all documents have errors', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'error' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) // Wait for initial fetch @@ -611,14 +522,12 @@ describe('EmbeddingProcess', () => { }) it('should stop polling when all documents are paused', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'paused' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) // Wait for initial fetch @@ -636,14 +545,12 @@ describe('EmbeddingProcess', () => { }) it('should cleanup timeout on unmount', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act const { unmount } = render(<EmbeddingProcess {...props} />) // Wait for initial fetch @@ -664,67 +571,52 @@ describe('EmbeddingProcess', () => { }) }) - // ========================================== - // User Interactions Tests - // ========================================== describe('User Interactions', () => { // Tests for button clicks and navigation it('should navigate to document list when nav button is clicked', async () => { - // Arrange const props = createDefaultProps({ datasetId: 'my-dataset-123' }) - // Act render(<EmbeddingProcess {...props} />) const navButton = screen.getByText('datasetCreation.stepThree.navTo') fireEvent.click(navButton) - // Assert expect(mockInvalidDocumentList).toHaveBeenCalled() expect(mockPush).toHaveBeenCalledWith('/datasets/my-dataset-123/documents') }) it('should call invalidDocumentList before navigation', () => { - // Arrange const props = createDefaultProps() const callOrder: string[] = [] mockInvalidDocumentList.mockImplementation(() => callOrder.push('invalidate')) mockPush.mockImplementation(() => callOrder.push('push')) - // Act render(<EmbeddingProcess {...props} />) const navButton = screen.getByText('datasetCreation.stepThree.navTo') fireEvent.click(navButton) - // Assert expect(callOrder).toEqual(['invalidate', 'push']) }) }) - // ========================================== // Document Display Tests - // ========================================== describe('Document Display', () => { // Tests for document list rendering it('should display document names', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'my-report.pdf' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('my-report.pdf')).toBeInTheDocument() }) it('should display multiple documents', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'file1.txt' }) const doc2 = createMockDocument({ id: 'doc-2', name: 'file2.pdf' }) mockIndexingStatusData = [ @@ -733,43 +625,35 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1, doc2] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('file1.txt')).toBeInTheDocument() expect(screen.getByText('file2.pdf')).toBeInTheDocument() }) it('should handle documents with special characters in names', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'report_2024 (final) - copy.pdf' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('report_2024 (final) - copy.pdf')).toBeInTheDocument() }) }) - // ========================================== // Data Source Type Tests - // ========================================== describe('Data Source Types', () => { // Tests for different data source type displays it('should handle local file data source', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'local-file.pdf', @@ -780,18 +664,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('local-file.pdf')).toBeInTheDocument() }) it('should handle online document data source', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'Notion Page', @@ -803,18 +684,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('Notion Page')).toBeInTheDocument() }) it('should handle website crawl data source', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'https://example.com/page', @@ -825,18 +703,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('https://example.com/page')).toBeInTheDocument() }) it('should handle online drive data source', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'Google Drive Document', @@ -847,24 +722,19 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('Google Drive Document')).toBeInTheDocument() }) }) - // ========================================== // Error Handling Tests - // ========================================== describe('Error Handling', () => { // Tests for error states and displays it('should display error icon for documents with error status', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -875,7 +745,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act const { container } = render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -887,7 +756,6 @@ describe('EmbeddingProcess', () => { }) it('should apply error styling to document row with error', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -898,7 +766,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act const { container } = render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -910,13 +777,9 @@ describe('EmbeddingProcess', () => { }) }) - // ========================================== - // Edge Cases - // ========================================== describe('Edge Cases', () => { // Tests for boundary conditions it('should throw error when documents array is empty', () => { - // Arrange // The component accesses documents[0].id for useProcessRule (line 81-82), // which throws TypeError when documents array is empty. // This test documents this known limitation. @@ -934,11 +797,9 @@ describe('EmbeddingProcess', () => { }) it('should handle empty indexing status response', async () => { - // Arrange mockIndexingStatusData = [] const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -951,7 +812,6 @@ describe('EmbeddingProcess', () => { }) it('should handle document with undefined name', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: undefined as unknown as string }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), @@ -963,7 +823,6 @@ describe('EmbeddingProcess', () => { }) it('should handle document not found in indexing status', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'other-doc', indexing_status: 'indexing' }), @@ -975,7 +834,6 @@ describe('EmbeddingProcess', () => { }) it('should handle undefined indexing_status', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -990,7 +848,6 @@ describe('EmbeddingProcess', () => { }) it('should handle mixed status documents', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) const doc2 = createMockDocument({ id: 'doc-2' }) const doc3 = createMockDocument({ id: 'doc-3' }) @@ -1001,7 +858,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1, doc2, doc3] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -1012,16 +868,12 @@ describe('EmbeddingProcess', () => { }) }) - // ========================================== // Props Variations Tests - // ========================================== describe('Props Variations', () => { // Tests for different prop combinations it('should handle undefined indexingType', () => { - // Arrange const props = createDefaultProps({ indexingType: undefined }) - // Act render(<EmbeddingProcess {...props} />) // Assert - component renders without crashing @@ -1029,10 +881,8 @@ describe('EmbeddingProcess', () => { }) it('should handle undefined retrievalMethod', () => { - // Arrange const props = createDefaultProps({ retrievalMethod: undefined }) - // Act render(<EmbeddingProcess {...props} />) // Assert - component renders without crashing @@ -1040,13 +890,11 @@ describe('EmbeddingProcess', () => { }) it('should pass different indexingType values', () => { - // Arrange const indexingTypes = [IndexingType.QUALIFIED, IndexingType.ECONOMICAL] indexingTypes.forEach((indexingType) => { const props = createDefaultProps({ indexingType }) - // Act const { unmount } = render(<EmbeddingProcess {...props} />) // Assert - RuleDetail renders and shows appropriate text based on indexingType @@ -1057,13 +905,11 @@ describe('EmbeddingProcess', () => { }) it('should pass different retrievalMethod values', () => { - // Arrange const retrievalMethods = [RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.fullText, RETRIEVE_METHOD.hybrid] retrievalMethods.forEach((retrievalMethod) => { const props = createDefaultProps({ retrievalMethod }) - // Act const { unmount } = render(<EmbeddingProcess {...props} />) // Assert - RuleDetail renders and shows appropriate text based on retrievalMethod @@ -1074,9 +920,6 @@ describe('EmbeddingProcess', () => { }) }) - // ========================================== - // Memoization Tests - // ========================================== describe('Memoization Logic', () => { // Tests for useMemo computed values it('should correctly compute isEmbeddingWaiting', async () => { @@ -1089,13 +932,11 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1, doc2] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.waiting')).toBeInTheDocument() }) @@ -1109,13 +950,11 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1, doc2] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() }) @@ -1131,24 +970,19 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1, doc2, doc3] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() }) }) - // ========================================== // File Type Detection Tests - // ========================================== describe('File Type Detection', () => { // Tests for getFileType helper function it('should extract file extension correctly', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'document.pdf', @@ -1159,7 +993,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -1170,7 +1003,6 @@ describe('EmbeddingProcess', () => { }) it('should handle files with multiple dots', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'my.report.2024.pdf', @@ -1181,18 +1013,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('my.report.2024.pdf')).toBeInTheDocument() }) it('should handle files without extension', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'README', @@ -1203,24 +1032,19 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('README')).toBeInTheDocument() }) }) - // ========================================== // Priority Label Tests - // ========================================== describe('Priority Label', () => { // Tests for priority label display it('should show priority label when billing is enabled', async () => { - // Arrange mockEnableBilling = true mockPlanType = Plan.sandbox const doc1 = createMockDocument({ id: 'doc-1' }) @@ -1229,7 +1053,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act const { container } = render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -1241,7 +1064,6 @@ describe('EmbeddingProcess', () => { }) it('should not show priority label when billing is disabled', async () => { - // Arrange mockEnableBilling = false const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ @@ -1249,7 +1071,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx similarity index 85% rename from web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx index 33b162d450..c11caeb156 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx @@ -4,13 +4,9 @@ import * as React from 'react' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ProcessMode } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import RuleDetail from './rule-detail' +import RuleDetail from '../rule-detail' -// ========================================== -// Mock External Dependencies -// ========================================== - -// Mock next/image (using img element for simplicity in tests) +// Override global next/image auto-mock: tests assert on data-testid="next-image" and src attributes vi.mock('next/image', () => ({ default: function MockImage({ src, alt, className }: { src: string, alt: string, className?: string }) { // eslint-disable-next-line next/no-img-element @@ -42,9 +38,7 @@ vi.mock('@/app/components/datasets/create/icons', () => ({ }, })) -// ========================================== // Test Data Factory Functions -// ========================================== /** * Creates a mock ProcessRuleResponse for testing @@ -71,33 +65,22 @@ const createMockProcessRule = (overrides: Partial<ProcessRuleResponse> = {}): Pr ...overrides, }) -// ========================================== -// Test Suite -// ========================================== - describe('RuleDetail', () => { beforeEach(() => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<RuleDetail />) - // Assert const fieldInfos = screen.getAllByTestId('field-info') expect(fieldInfos).toHaveLength(3) }) it('should render three FieldInfo components', () => { - // Arrange const sourceData = createMockProcessRule() - // Act render( <RuleDetail sourceData={sourceData} @@ -106,13 +89,11 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldInfos = screen.getAllByTestId('field-info') expect(fieldInfos).toHaveLength(3) }) it('should render mode field with correct label', () => { - // Arrange & Act render(<RuleDetail />) // Assert - first field-info is for mode @@ -121,45 +102,34 @@ describe('RuleDetail', () => { }) }) - // ========================================== // Mode Value Tests - // ========================================== describe('Mode Value', () => { it('should show "-" when sourceData is undefined', () => { - // Arrange & Act render(<RuleDetail />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('-') }) it('should show "-" when sourceData.mode is undefined', () => { - // Arrange const sourceData = { ...createMockProcessRule(), mode: undefined as unknown as ProcessMode } - // Act render(<RuleDetail sourceData={sourceData} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('-') }) it('should show custom mode text when mode is general', () => { - // Arrange const sourceData = createMockProcessRule({ mode: ProcessMode.general }) - // Act render(<RuleDetail sourceData={sourceData} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.custom') }) it('should show hierarchical mode with paragraph parent mode', () => { - // Arrange const sourceData = createMockProcessRule({ mode: ProcessMode.parentChild, rules: { @@ -170,16 +140,13 @@ describe('RuleDetail', () => { }, }) - // Act render(<RuleDetail sourceData={sourceData} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.hierarchical · dataset.parentMode.paragraph') }) it('should show hierarchical mode with full-doc parent mode', () => { - // Arrange const sourceData = createMockProcessRule({ mode: ProcessMode.parentChild, rules: { @@ -190,24 +157,18 @@ describe('RuleDetail', () => { }, }) - // Act render(<RuleDetail sourceData={sourceData} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.hierarchical · dataset.parentMode.fullDoc') }) }) - // ========================================== // Indexing Type Tests - // ========================================== describe('Indexing Type', () => { it('should show qualified indexing type', () => { - // Arrange & Act render(<RuleDetail indexingType={IndexingType.QUALIFIED} />) - // Assert const fieldInfos = screen.getAllByTestId('field-info') expect(fieldInfos[1]).toHaveAttribute('data-label', 'datasetCreation.stepTwo.indexMode') @@ -216,48 +177,37 @@ describe('RuleDetail', () => { }) it('should show economical indexing type', () => { - // Arrange & Act render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.economical') }) it('should show high_quality icon for qualified indexing', () => { - // Arrange & Act render(<RuleDetail indexingType={IndexingType.QUALIFIED} />) - // Assert const images = screen.getAllByTestId('next-image') expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg') }) it('should show economical icon for economical indexing', () => { - // Arrange & Act render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />) - // Assert const images = screen.getAllByTestId('next-image') expect(images[0]).toHaveAttribute('src', '/icons/economical.svg') }) }) - // ========================================== // Retrieval Method Tests - // ========================================== describe('Retrieval Method', () => { it('should show retrieval setting label', () => { - // Arrange & Act render(<RuleDetail retrievalMethod={RETRIEVE_METHOD.semantic} />) - // Assert const fieldInfos = screen.getAllByTestId('field-info') expect(fieldInfos[2]).toHaveAttribute('data-label', 'datasetSettings.form.retrievalSetting.title') }) it('should show semantic search title for qualified indexing with semantic method', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -265,13 +215,11 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.semantic_search.title') }) it('should show full text search title for fullText method', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -279,13 +227,11 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.full_text_search.title') }) it('should show hybrid search title for hybrid method', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -293,13 +239,11 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.hybrid_search.title') }) it('should force keyword_search for economical indexing type', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.ECONOMICAL} @@ -307,13 +251,11 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.keyword_search.title') }) it('should show vector icon for semantic search', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -321,13 +263,11 @@ describe('RuleDetail', () => { />, ) - // Assert const images = screen.getAllByTestId('next-image') expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') }) it('should show fullText icon for full text search', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -335,13 +275,11 @@ describe('RuleDetail', () => { />, ) - // Assert const images = screen.getAllByTestId('next-image') expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg') }) it('should show hybrid icon for hybrid search', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -349,46 +287,35 @@ describe('RuleDetail', () => { />, ) - // Assert const images = screen.getAllByTestId('next-image') expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg') }) }) - // ========================================== - // Edge Cases - // ========================================== describe('Edge Cases', () => { it('should handle all props undefined', () => { - // Arrange & Act render(<RuleDetail />) - // Assert expect(screen.getAllByTestId('field-info')).toHaveLength(3) }) it('should handle undefined indexingType with defined retrievalMethod', () => { - // Arrange & Act render(<RuleDetail retrievalMethod={RETRIEVE_METHOD.hybrid} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') // When indexingType is undefined, it's treated as qualified expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.qualified') }) it('should handle undefined retrievalMethod with defined indexingType', () => { - // Arrange & Act render(<RuleDetail indexingType={IndexingType.QUALIFIED} />) - // Assert const images = screen.getAllByTestId('next-image') // When retrievalMethod is undefined, vector icon is used as default expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') }) it('should handle sourceData with null rules', () => { - // Arrange const sourceData = { ...createMockProcessRule(), mode: ProcessMode.parentChild, @@ -401,15 +328,11 @@ describe('RuleDetail', () => { }) }) - // ========================================== // Props Variations Tests - // ========================================== describe('Props Variations', () => { it('should render correctly with all props provided', () => { - // Arrange const sourceData = createMockProcessRule({ mode: ProcessMode.general }) - // Act render( <RuleDetail sourceData={sourceData} @@ -418,7 +341,6 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.custom') expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.qualified') @@ -426,10 +348,8 @@ describe('RuleDetail', () => { }) it('should render correctly for economical mode with full settings', () => { - // Arrange const sourceData = createMockProcessRule({ mode: ProcessMode.parentChild }) - // Act render( <RuleDetail sourceData={sourceData} @@ -438,7 +358,6 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.economical') // Economical always uses keyword_search regardless of retrievalMethod @@ -446,9 +365,6 @@ describe('RuleDetail', () => { }) }) - // ========================================== - // Memoization Tests - // ========================================== describe('Memoization', () => { it('should be wrapped in React.memo', () => { // Assert - RuleDetail should be a memoized component @@ -456,7 +372,6 @@ describe('RuleDetail', () => { }) it('should not re-render with same props', () => { - // Arrange const sourceData = createMockProcessRule() const props = { sourceData, @@ -464,7 +379,6 @@ describe('RuleDetail', () => { retrievalMethod: RETRIEVE_METHOD.semantic, } - // Act const { rerender } = render(<RuleDetail {...props} />) rerender(<RuleDetail {...props} />) diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx index d4893c9d2d..11f1286306 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx @@ -6,7 +6,7 @@ import type { OnlineDriveFile } from '@/models/pipeline' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DatasourceType } from '@/models/pipeline' -import { StepOnePreview, StepTwoPreview } from './preview-panel' +import { StepOnePreview, StepTwoPreview } from '../preview-panel' // Mock context hooks (ćș•ć±‚äŸè”–) vi.mock('@/context/dataset-detail', () => ({ @@ -38,7 +38,7 @@ vi.mock('@/service/use-pipeline', () => ({ })) // Mock data source store -vi.mock('../data-source/store', () => ({ +vi.mock('../../data-source/store', () => ({ useDataSourceStore: vi.fn(() => ({ getState: () => ({ currentCredentialId: 'mock-credential-id' }), })), diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx similarity index 97% rename from web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx index 0db366221b..ff0c1b125c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx @@ -4,7 +4,7 @@ import type { Node } from '@/app/components/workflow/types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DatasourceType } from '@/models/pipeline' -import StepOneContent from './step-one-content' +import StepOneContent from '../step-one-content' // Mock context providers and hooks (ćș•ć±‚äŸè”–) vi.mock('@/context/modal-context', () => ({ @@ -25,7 +25,7 @@ vi.mock('@/app/components/billing/upgrade-btn', () => ({ })) // Mock data source store -vi.mock('../data-source/store', () => ({ +vi.mock('../../data-source/store', () => ({ useDataSourceStore: vi.fn(() => ({ getState: () => ({ localFileList: [], @@ -57,19 +57,19 @@ vi.mock('@/service/use-common', () => ({ })) // Mock hooks used by data source options -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useDatasourceOptions: vi.fn(() => [ { label: 'Local File', value: 'node-1', data: { type: 'data-source' } }, ]), })) // Mock useDatasourceIcon hook to avoid complex data source list transformation -vi.mock('../data-source-options/hooks', () => ({ +vi.mock('../../data-source-options/hooks', () => ({ useDatasourceIcon: vi.fn(() => '/icons/local-file.svg'), })) // Mock the entire local-file component since it has deep context dependencies -vi.mock('../data-source/local-file', () => ({ +vi.mock('../../data-source/local-file', () => ({ default: ({ allowedExtensions, supportBatchUpload }: { allowedExtensions: string[] supportBatchUpload: boolean @@ -83,7 +83,7 @@ vi.mock('../data-source/local-file', () => ({ })) // Mock online documents since it has complex OAuth/API dependencies -vi.mock('../data-source/online-documents', () => ({ +vi.mock('../../data-source/online-documents', () => ({ default: ({ nodeId, onCredentialChange }: { nodeId: string onCredentialChange: (credentialId: string) => void @@ -98,7 +98,7 @@ vi.mock('../data-source/online-documents', () => ({ })) // Mock website crawl -vi.mock('../data-source/website-crawl', () => ({ +vi.mock('../../data-source/website-crawl', () => ({ default: ({ nodeId, onCredentialChange }: { nodeId: string onCredentialChange: (credentialId: string) => void @@ -113,7 +113,7 @@ vi.mock('../data-source/website-crawl', () => ({ })) // Mock online drive -vi.mock('../data-source/online-drive', () => ({ +vi.mock('../../data-source/online-drive', () => ({ default: ({ nodeId, onCredentialChange }: { nodeId: string onCredentialChange: (credentialId: string) => void @@ -143,7 +143,6 @@ vi.mock('@/service/base', () => ({ upload: vi.fn().mockResolvedValue({ id: 'uploaded-file-id' }), })) -// Mock next/navigation vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'mock-dataset-id' }), useRouter: () => ({ push: vi.fn() }), diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-three-content.spec.tsx similarity index 96% rename from web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-three-content.spec.tsx index 9593e59c93..e217248d2b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-three-content.spec.tsx @@ -1,7 +1,7 @@ import type { InitialDocumentDetail } from '@/models/pipeline' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import StepThreeContent from './step-three-content' +import StepThreeContent from '../step-three-content' // Mock context hooks used by Processing component vi.mock('@/context/dataset-detail', () => ({ @@ -24,7 +24,7 @@ vi.mock('@/context/i18n', () => ({ })) // Mock EmbeddingProcess component as it has complex dependencies -vi.mock('../processing/embedding-process', () => ({ +vi.mock('../../processing/embedding-process', () => ({ default: ({ datasetId, batchId, documents }: { datasetId: string batchId: string diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/step-two-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-two-content.spec.tsx similarity index 97% rename from web/app/components/datasets/documents/create-from-pipeline/steps/step-two-content.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-two-content.spec.tsx index 4890f3b500..84cf96aaa9 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/step-two-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-two-content.spec.tsx @@ -1,10 +1,10 @@ import type { RefObject } from 'react' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import StepTwoContent from './step-two-content' +import StepTwoContent from '../step-two-content' // Mock ProcessDocuments component as it has complex hook dependencies -vi.mock('../process-documents', () => ({ +vi.mock('../../process-documents', () => ({ default: vi.fn().mockImplementation(({ dataSourceNodeId, isRunning, diff --git a/web/app/components/datasets/documents/create-from-pipeline/utils/__tests__/datasource-info-builder.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/utils/__tests__/datasource-info-builder.spec.ts new file mode 100644 index 0000000000..6085dbf4b4 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/utils/__tests__/datasource-info-builder.spec.ts @@ -0,0 +1,104 @@ +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem, CustomFile } from '@/models/datasets' +import type { OnlineDriveFile } from '@/models/pipeline' +import { describe, expect, it } from 'vitest' +import { OnlineDriveFileType } from '@/models/pipeline' +import { TransferMethod } from '@/types/app' +import { + buildLocalFileDatasourceInfo, + buildOnlineDocumentDatasourceInfo, + buildOnlineDriveDatasourceInfo, + buildWebsiteCrawlDatasourceInfo, +} from '../datasource-info-builder' + +describe('datasource-info-builder', () => { + describe('buildLocalFileDatasourceInfo', () => { + const file: CustomFile = { + id: 'file-1', + name: 'test.pdf', + type: 'application/pdf', + size: 1024, + extension: 'pdf', + mime_type: 'application/pdf', + } as unknown as CustomFile + + it('should build local file datasource info', () => { + const result = buildLocalFileDatasourceInfo(file, 'cred-1') + expect(result).toEqual({ + related_id: 'file-1', + name: 'test.pdf', + type: 'application/pdf', + size: 1024, + extension: 'pdf', + mime_type: 'application/pdf', + url: '', + transfer_method: TransferMethod.local_file, + credential_id: 'cred-1', + }) + }) + + it('should use empty credential when not provided', () => { + const result = buildLocalFileDatasourceInfo(file, '') + expect(result.credential_id).toBe('') + }) + }) + + describe('buildOnlineDocumentDatasourceInfo', () => { + const page = { + page_id: 'page-1', + page_name: 'My Page', + workspace_id: 'ws-1', + parent_id: 'root', + type: 'page', + } as NotionPage & { workspace_id: string } + + it('should build online document info with workspace_id separated', () => { + const result = buildOnlineDocumentDatasourceInfo(page, 'cred-2') + expect(result.workspace_id).toBe('ws-1') + expect(result.credential_id).toBe('cred-2') + expect((result.page as unknown as Record<string, unknown>).page_id).toBe('page-1') + // workspace_id should not be in the page object + expect((result.page as unknown as Record<string, unknown>).workspace_id).toBeUndefined() + }) + }) + + describe('buildWebsiteCrawlDatasourceInfo', () => { + const crawlResult: CrawlResultItem = { + source_url: 'https://example.com', + title: 'Example', + markdown: '# Hello', + description: 'desc', + } as unknown as CrawlResultItem + + it('should spread crawl result and add credential_id', () => { + const result = buildWebsiteCrawlDatasourceInfo(crawlResult, 'cred-3') + expect(result.source_url).toBe('https://example.com') + expect(result.title).toBe('Example') + expect(result.credential_id).toBe('cred-3') + }) + }) + + describe('buildOnlineDriveDatasourceInfo', () => { + const file: OnlineDriveFile = { + id: 'drive-1', + name: 'doc.xlsx', + type: OnlineDriveFileType.file, + } + + it('should build online drive info with bucket', () => { + const result = buildOnlineDriveDatasourceInfo(file, 'my-bucket', 'cred-4') + expect(result).toEqual({ + bucket: 'my-bucket', + id: 'drive-1', + name: 'doc.xlsx', + type: 'file', + credential_id: 'cred-4', + }) + }) + + it('should handle empty bucket', () => { + const result = buildOnlineDriveDatasourceInfo(file, '', 'cred-4') + expect(result.bucket).toBe('') + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/document-title.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/detail/document-title.spec.tsx rename to web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx index dca2d068ec..e7945fc409 100644 --- a/web/app/components/datasets/documents/detail/document-title.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx @@ -2,9 +2,8 @@ import { render } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import { DocumentTitle } from './document-title' +import { DocumentTitle } from '../document-title' -// Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -13,7 +12,7 @@ vi.mock('next/navigation', () => ({ })) // Mock DocumentPicker -vi.mock('../../common/document-picker', () => ({ +vi.mock('../../../common/document-picker', () => ({ default: ({ datasetId, value, onChange }: { datasetId: string, value: unknown, onChange: (doc: { id: string }) => void }) => ( <div data-testid="document-picker" @@ -31,35 +30,28 @@ describe('DocumentTitle', () => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render DocumentPicker component', () => { - // Arrange & Act const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Assert expect(getByTestId('document-picker')).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('flex-1') @@ -68,20 +60,16 @@ describe('DocumentTitle', () => { }) }) - // Props tests describe('Props', () => { it('should pass datasetId to DocumentPicker', () => { - // Arrange & Act const { getByTestId } = render( <DocumentTitle datasetId="test-dataset-id" />, ) - // Assert expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('test-dataset-id') }) it('should pass value props to DocumentPicker', () => { - // Arrange & Act const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" @@ -92,7 +80,6 @@ describe('DocumentTitle', () => { />, ) - // Assert const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') expect(value.name).toBe('test-document') expect(value.extension).toBe('pdf') @@ -101,68 +88,54 @@ describe('DocumentTitle', () => { }) it('should default parentMode to paragraph when parent_mode is undefined', () => { - // Arrange & Act const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Assert const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') expect(value.parentMode).toBe('paragraph') }) it('should apply custom wrapperCls', () => { - // Arrange & Act const { container } = render( <DocumentTitle datasetId="dataset-1" wrapperCls="custom-wrapper" />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-wrapper') }) }) - // Navigation tests describe('Navigation', () => { it('should navigate to document page when document is selected', () => { - // Arrange const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Act getByTestId('document-picker').click() - // Assert expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/new-doc-id') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined optional props', () => { - // Arrange & Act const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Assert const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') expect(value.name).toBeUndefined() expect(value.extension).toBeUndefined() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender, getByTestId } = render( <DocumentTitle datasetId="dataset-1" name="doc1" />, ) - // Act rerender(<DocumentTitle datasetId="dataset-2" name="doc2" />) - // Assert expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('dataset-2') }) }) diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ad8741a8e1 --- /dev/null +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -0,0 +1,454 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// --- All hoisted mock fns and state (accessible inside vi.mock factories) --- +const mocks = vi.hoisted(() => { + const state = { + dataset: { embedding_available: true } as Record<string, unknown> | null, + documentDetail: null as Record<string, unknown> | null, + documentError: null as Error | null, + documentMetadata: null as Record<string, unknown> | null, + media: 'desktop' as string, + } + return { + state, + push: vi.fn(), + detailRefetch: vi.fn(), + checkProgress: vi.fn(), + batchImport: vi.fn(), + invalidDocumentList: vi.fn(), + invalidSegmentList: vi.fn(), + invalidChildSegmentList: vi.fn(), + toastNotify: vi.fn(), + } +}) + +// --- External mocks --- +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => mocks.state.media, + MediaType: { mobile: 'mobile', tablet: 'tablet', pc: 'desktop' }, +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (s: Record<string, unknown>) => unknown) => + selector({ dataset: mocks.state.dataset }), +})) + +vi.mock('@/service/knowledge/use-document', () => ({ + useDocumentDetail: () => ({ + data: mocks.state.documentDetail, + error: mocks.state.documentError, + refetch: mocks.detailRefetch, + }), + useDocumentMetadata: () => ({ + data: mocks.state.documentMetadata, + }), + useInvalidDocumentList: () => mocks.invalidDocumentList, +})) + +vi.mock('@/service/knowledge/use-segment', () => ({ + useCheckSegmentBatchImportProgress: () => ({ + mutateAsync: mocks.checkProgress, + }), + useSegmentBatchImport: () => ({ + mutateAsync: mocks.batchImport, + }), + useSegmentListKey: ['segment-list'], + useChildSegmentListKey: ['child-segment-list'], +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: (key: unknown) => { + const keyStr = JSON.stringify(key) + if (keyStr === JSON.stringify(['segment-list'])) + return mocks.invalidSegmentList + if (keyStr === JSON.stringify(['child-segment-list'])) + return mocks.invalidChildSegmentList + return vi.fn() + }, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: mocks.toastNotify }, +})) + +// --- Child component mocks --- +vi.mock('../completed', () => ({ + default: ({ embeddingAvailable, showNewSegmentModal, archived }: { embeddingAvailable?: boolean, showNewSegmentModal?: () => void, archived?: boolean }) => ( + <div + data-testid="completed" + data-embedding-available={embeddingAvailable} + data-show-new-segment={showNewSegmentModal} + data-archived={archived} + > + Completed + </div> + ), +})) + +vi.mock('../embedding', () => ({ + default: ({ detailUpdate }: { detailUpdate?: () => void }) => ( + <div data-testid="embedding"> + <button data-testid="embedding-refresh" onClick={detailUpdate}>Refresh</button> + </div> + ), +})) + +vi.mock('../batch-modal', () => ({ + default: ({ isShow, onCancel, onConfirm }: { isShow?: boolean, onCancel?: () => void, onConfirm?: (val: Record<string, unknown>) => void }) => ( + isShow + ? ( + <div data-testid="batch-modal"> + <button data-testid="batch-cancel" onClick={onCancel}>Cancel</button> + <button data-testid="batch-confirm" onClick={() => onConfirm?.({ file: { id: 'file-1' } })}>Confirm</button> + </div> + ) + : null + ), +})) + +vi.mock('../document-title', () => ({ + DocumentTitle: ({ name, extension }: { name?: string, extension?: string }) => ( + <div data-testid="document-title" data-extension={extension}>{name}</div> + ), +})) + +vi.mock('../segment-add', () => ({ + default: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => ( + <div data-testid="segment-add" data-embedding={embedding}> + <button data-testid="new-segment-btn" onClick={showNewSegmentModal}>New Segment</button> + <button data-testid="batch-btn" onClick={showBatchModal}>Batch Import</button> + </div> + ), + ProcessStatus: { + WAITING: 'waiting', + PROCESSING: 'processing', + ERROR: 'error', + COMPLETED: 'completed', + }, +})) + +vi.mock('../../components/operations', () => ({ + default: ({ onUpdate, scene }: { onUpdate?: (action?: string) => void, scene?: string }) => ( + <div data-testid="operations" data-scene={scene}> + <button data-testid="op-rename" onClick={() => onUpdate?.('rename')}>Rename</button> + <button data-testid="op-delete" onClick={() => onUpdate?.('delete')}>Delete</button> + <button data-testid="op-noop" onClick={() => onUpdate?.()}>NoOp</button> + </div> + ), +})) + +vi.mock('../../status-item', () => ({ + default: ({ status, scene }: { status?: string, scene?: string }) => ( + <div data-testid="status-item" data-scene={scene}>{status}</div> + ), +})) + +vi.mock('@/app/components/datasets/metadata/metadata-document', () => ({ + default: ({ datasetId, documentId }: { datasetId?: string, documentId?: string }) => ( + <div data-testid="metadata" data-dataset-id={datasetId} data-document-id={documentId}>Metadata</div> + ), +})) + +vi.mock('@/app/components/base/float-right-container', () => ({ + default: ({ children, isOpen, onClose }: { children?: React.ReactNode, isOpen?: boolean, onClose?: () => void }) => + isOpen + ? ( + <div data-testid="float-right-container"> + <button data-testid="close-metadata" onClick={onClose}>Close</button> + {children} + </div> + ) + : null, +})) + +// --- Lazy import (after all vi.mock calls) --- +const { default: DocumentDetail } = await import('../index') + +// --- Factory --- +const createDocumentDetail = (overrides?: Record<string, unknown>) => ({ + name: 'test-doc.txt', + display_status: 'available', + enabled: true, + archived: false, + doc_form: 'text_model', + data_source_type: 'upload_file', + data_source_info: { upload_file: { extension: '.txt' } }, + error: '', + document_process_rule: null, + dataset_process_rule: null, + ...overrides, +}) + +describe('DocumentDetail', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mocks.state.dataset = { embedding_available: true } + mocks.state.documentDetail = createDocumentDetail() + mocks.state.documentError = null + mocks.state.documentMetadata = null + mocks.state.media = 'desktop' + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('Loading State', () => { + it('should show loading when no data and no error', () => { + mocks.state.documentDetail = null + mocks.state.documentError = null + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('completed')).not.toBeInTheDocument() + expect(screen.queryByTestId('embedding')).not.toBeInTheDocument() + }) + + it('should not show loading when error exists', () => { + mocks.state.documentDetail = null + mocks.state.documentError = new Error('Not found') + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('completed')).toBeInTheDocument() + }) + }) + + describe('Content Rendering', () => { + it('should render Completed when status is available', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('completed')).toBeInTheDocument() + expect(screen.queryByTestId('embedding')).not.toBeInTheDocument() + }) + + it.each(['queuing', 'indexing', 'paused'])('should render Embedding when status is %s', (status) => { + mocks.state.documentDetail = createDocumentDetail({ display_status: status }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('embedding')).toBeInTheDocument() + expect(screen.queryByTestId('completed')).not.toBeInTheDocument() + }) + + it('should render DocumentTitle with name and extension', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + const title = screen.getByTestId('document-title') + expect(title).toHaveTextContent('test-doc.txt') + expect(title).toHaveAttribute('data-extension', '.txt') + }) + + it('should render StatusItem with correct status and scene', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + const statusItem = screen.getByTestId('status-item') + expect(statusItem).toHaveTextContent('available') + expect(statusItem).toHaveAttribute('data-scene', 'detail') + }) + + it('should render Operations with scene=detail', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('operations')).toHaveAttribute('data-scene', 'detail') + }) + }) + + describe('SegmentAdd Visibility', () => { + it('should show SegmentAdd when all conditions met', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('segment-add')).toBeInTheDocument() + }) + + it('should hide SegmentAdd when embedding is not available', () => { + mocks.state.dataset = { embedding_available: false } + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() + }) + + it('should hide SegmentAdd when document is archived', () => { + mocks.state.documentDetail = createDocumentDetail({ archived: true }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() + }) + + it('should hide SegmentAdd in full-doc parent-child mode', () => { + mocks.state.documentDetail = createDocumentDetail({ + doc_form: 'hierarchical_model', + document_process_rule: { rules: { parent_mode: 'full-doc' } }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() + }) + }) + + describe('Metadata Panel', () => { + it('should show metadata panel by default on desktop', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('float-right-container')).toBeInTheDocument() + expect(screen.getByTestId('metadata')).toBeInTheDocument() + }) + + it('should toggle metadata panel when button clicked', () => { + const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('metadata')).toBeInTheDocument() + + const svgs = container.querySelectorAll('svg') + const toggleBtn = svgs[svgs.length - 1].closest('button')! + fireEvent.click(toggleBtn) + expect(screen.queryByTestId('metadata')).not.toBeInTheDocument() + }) + + it('should pass correct props to Metadata', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + const metadata = screen.getByTestId('metadata') + expect(metadata).toHaveAttribute('data-dataset-id', 'ds-1') + expect(metadata).toHaveAttribute('data-document-id', 'doc-1') + }) + }) + + describe('Navigation', () => { + it('should navigate back when back button clicked', () => { + const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + const backBtn = container.querySelector('svg')!.parentElement! + fireEvent.click(backBtn) + expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents') + }) + + it('should preserve query params when navigating back', () => { + const origLocation = window.location + window.history.pushState({}, '', '?page=2&status=active') + const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + const backBtn = container.querySelector('svg')!.parentElement! + fireEvent.click(backBtn) + expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents?page=2&status=active') + window.history.pushState({}, '', origLocation.href) + }) + }) + + describe('handleOperate', () => { + it('should invalidate document list on any operation', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('op-rename')) + expect(mocks.invalidDocumentList).toHaveBeenCalled() + }) + + it('should navigate back on delete operation', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('op-delete')) + expect(mocks.invalidDocumentList).toHaveBeenCalled() + expect(mocks.push).toHaveBeenCalled() + }) + + it('should refresh detail on non-delete operation', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('op-rename')) + expect(mocks.detailRefetch).toHaveBeenCalled() + }) + + it('should invalidate chunk lists after 5s on named non-delete operation', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('op-rename')) + + expect(mocks.invalidSegmentList).not.toHaveBeenCalled() + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(mocks.invalidSegmentList).toHaveBeenCalled() + expect(mocks.invalidChildSegmentList).toHaveBeenCalled() + }) + + it('should not invalidate chunk lists on operation with no name', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('op-noop')) + + expect(mocks.detailRefetch).toHaveBeenCalled() + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(mocks.invalidSegmentList).not.toHaveBeenCalled() + }) + }) + + describe('Batch Import', () => { + it('should open batch modal when batch button clicked', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('batch-modal')).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('batch-btn')) + expect(screen.getByTestId('batch-modal')).toBeInTheDocument() + }) + + it('should close batch modal when cancel clicked', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('batch-btn')) + expect(screen.getByTestId('batch-modal')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('batch-cancel')) + expect(screen.queryByTestId('batch-modal')).not.toBeInTheDocument() + }) + + it('should call segmentBatchImport on confirm', async () => { + mocks.batchImport.mockResolvedValue({ job_id: 'job-1', job_status: 'waiting' }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('batch-btn')) + + await act(async () => { + fireEvent.click(screen.getByTestId('batch-confirm')) + }) + + expect(mocks.batchImport).toHaveBeenCalledWith( + { + url: '/datasets/ds-1/documents/doc-1/segments/batch_import', + body: { upload_file_id: 'file-1' }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) + }) + }) + + describe('isFullDocMode', () => { + it('should detect full-doc mode from document_process_rule', () => { + mocks.state.documentDetail = createDocumentDetail({ + doc_form: 'hierarchical_model', + document_process_rule: { rules: { parent_mode: 'full-doc' } }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() + }) + + it('should detect full-doc mode from dataset_process_rule as fallback', () => { + mocks.state.documentDetail = createDocumentDetail({ + doc_form: 'hierarchical_model', + document_process_rule: null, + dataset_process_rule: { rules: { parent_mode: 'full-doc' } }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() + }) + + it('should not be full-doc when parentMode is paragraph', () => { + mocks.state.documentDetail = createDocumentDetail({ + doc_form: 'hierarchical_model', + document_process_rule: { rules: { parent_mode: 'paragraph' } }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('segment-add')).toBeInTheDocument() + }) + }) + + describe('Legacy DataSourceInfo', () => { + it('should extract extension from legacy data_source_info', () => { + mocks.state.documentDetail = createDocumentDetail({ + data_source_info: { upload_file: { extension: '.pdf' } }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('document-title')).toHaveAttribute('data-extension', '.pdf') + }) + + it('should handle non-legacy data_source_info gracefully', () => { + mocks.state.documentDetail = createDocumentDetail({ + data_source_info: { url: 'https://example.com' }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('document-title')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx similarity index 61% rename from web/app/components/datasets/documents/detail/new-segment.spec.tsx rename to web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx index 7fc94ab80f..73082108a0 100644 --- a/web/app/components/datasets/documents/detail/new-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx @@ -1,11 +1,11 @@ +import type * as React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import { IndexingType } from '../../create/step-two' +import { IndexingType } from '../../../create/step-two' -import NewSegmentModal from './new-segment' +import NewSegmentModal from '../new-segment' -// Mock next/navigation vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id', @@ -13,7 +13,6 @@ vi.mock('next/navigation', () => ({ }), })) -// Mock ToastContext const mockNotify = vi.fn() vi.mock('use-context-selector', async (importOriginal) => { const actual = await importOriginal() as Record<string, unknown> @@ -34,7 +33,7 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock segment list context let mockFullScreen = false const mockToggleFullScreen = vi.fn() -vi.mock('./completed', () => ({ +vi.mock('../completed', () => ({ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { const state = { fullScreen: mockFullScreen, @@ -57,8 +56,7 @@ vi.mock('@/app/components/app/store', () => ({ useStore: () => ({ appSidebarExpand: 'expand' }), })) -// Mock child components -vi.mock('./completed/common/action-buttons', () => ({ +vi.mock('../completed/common/action-buttons', () => ({ default: ({ handleCancel, handleSave, loading, actionType }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string }) => ( <div data-testid="action-buttons"> <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> @@ -70,7 +68,7 @@ vi.mock('./completed/common/action-buttons', () => ({ ), })) -vi.mock('./completed/common/add-another', () => ({ +vi.mock('../completed/common/add-another', () => ({ default: ({ isChecked, onCheck, className }: { isChecked: boolean, onCheck: () => void, className?: string }) => ( <div data-testid="add-another" className={className}> <input @@ -83,7 +81,7 @@ vi.mock('./completed/common/add-another', () => ({ ), })) -vi.mock('./completed/common/chunk-content', () => ({ +vi.mock('../completed/common/chunk-content', () => ({ default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => ( <div data-testid="chunk-content"> <input @@ -105,11 +103,11 @@ vi.mock('./completed/common/chunk-content', () => ({ ), })) -vi.mock('./completed/common/dot', () => ({ +vi.mock('../completed/common/dot', () => ({ default: () => <span data-testid="dot">‱</span>, })) -vi.mock('./completed/common/keywords', () => ({ +vi.mock('../completed/common/keywords', () => ({ default: ({ keywords, onKeywordsChange, _isEditMode, _actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, _actionType?: string }) => ( <div data-testid="keywords"> <input @@ -121,7 +119,7 @@ vi.mock('./completed/common/keywords', () => ({ ), })) -vi.mock('./completed/common/segment-index-tag', () => ({ +vi.mock('../completed/common/segment-index-tag', () => ({ SegmentIndexTag: ({ label }: { label: string }) => <span data-testid="segment-index-tag">{label}</span>, })) @@ -152,53 +150,40 @@ describe('NewSegmentModal', () => { viewNewlyAddedChunk: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<NewSegmentModal {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render title text', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.addChunk/i)).toBeInTheDocument() }) it('should render chunk content component', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('chunk-content')).toBeInTheDocument() }) it('should render image uploader', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('image-uploader')).toBeInTheDocument() }) it('should render segment index tag', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument() }) it('should render dot separator', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('dot')).toBeInTheDocument() }) }) @@ -206,32 +191,24 @@ describe('NewSegmentModal', () => { // Keywords display describe('Keywords', () => { it('should show keywords component when indexing is ECONOMICAL', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL - // Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('keywords')).toBeInTheDocument() }) it('should not show keywords when indexing is QUALIFIED', () => { - // Arrange mockIndexingTechnique = IndexingType.QUALIFIED - // Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.queryByTestId('keywords')).not.toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() const { container } = render(<NewSegmentModal {...defaultProps} onCancel={mockOnCancel} />) @@ -241,40 +218,31 @@ describe('NewSegmentModal', () => { if (closeButtons.length > 1) fireEvent.click(closeButtons[1]) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) it('should update question when typing', () => { - // Arrange render(<NewSegmentModal {...defaultProps} />) const questionInput = screen.getByTestId('question-input') - // Act fireEvent.change(questionInput, { target: { value: 'New question content' } }) - // Assert expect(questionInput).toHaveValue('New question content') }) it('should update answer when docForm is QA and typing', () => { - // Arrange render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) const answerInput = screen.getByTestId('answer-input') - // Act fireEvent.change(answerInput, { target: { value: 'New answer content' } }) - // Assert expect(answerInput).toHaveValue('New answer content') }) it('should toggle add another checkbox', () => { - // Arrange render(<NewSegmentModal {...defaultProps} />) const checkbox = screen.getByTestId('add-another-checkbox') - // Act fireEvent.click(checkbox) // Assert - checkbox state should toggle @@ -285,13 +253,10 @@ describe('NewSegmentModal', () => { // Save validation describe('Save Validation', () => { it('should show error when content is empty for text mode', async () => { - // Arrange render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -302,13 +267,10 @@ describe('NewSegmentModal', () => { }) it('should show error when question is empty for QA mode', async () => { - // Arrange render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -319,14 +281,11 @@ describe('NewSegmentModal', () => { }) it('should show error when answer is empty for QA mode', async () => { - // Arrange render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Question' } }) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -340,7 +299,6 @@ describe('NewSegmentModal', () => { // Successful save describe('Successful Save', () => { it('should call addSegment when valid content is provided for text mode', async () => { - // Arrange mockAddSegment.mockImplementation((_params, options) => { options.onSuccess() options.onSettled() @@ -350,10 +308,8 @@ describe('NewSegmentModal', () => { render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Valid content' } }) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockAddSegment).toHaveBeenCalledWith( expect.objectContaining({ @@ -369,7 +325,6 @@ describe('NewSegmentModal', () => { }) it('should show success notification after save', async () => { - // Arrange mockAddSegment.mockImplementation((_params, options) => { options.onSuccess() options.onSettled() @@ -379,10 +334,8 @@ describe('NewSegmentModal', () => { render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Valid content' } }) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -396,41 +349,31 @@ describe('NewSegmentModal', () => { // Full screen mode describe('Full Screen Mode', () => { it('should apply full screen styling when fullScreen is true', () => { - // Arrange mockFullScreen = true - // Act const { container } = render(<NewSegmentModal {...defaultProps} />) - // Assert const header = container.querySelector('.border-divider-subtle') expect(header).toBeInTheDocument() }) it('should show action buttons in header when fullScreen', () => { - // Arrange mockFullScreen = true - // Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) it('should show add another in header when fullScreen', () => { - // Arrange mockFullScreen = true - // Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('add-another')).toBeInTheDocument() }) it('should call toggleFullScreen when expand button is clicked', () => { - // Arrange const { container } = render(<NewSegmentModal {...defaultProps} />) // Act - click the expand button (first cursor-pointer) @@ -438,7 +381,6 @@ describe('NewSegmentModal', () => { if (expandButtons.length > 0) fireEvent.click(expandButtons[0]) - // Assert expect(mockToggleFullScreen).toHaveBeenCalled() }) }) @@ -446,43 +388,33 @@ describe('NewSegmentModal', () => { // Props describe('Props', () => { it('should pass actionType add to ActionButtons', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('action-type')).toHaveTextContent('add') }) it('should pass isEditMode true to ChunkContent', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle keyword changes for ECONOMICAL indexing', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL render(<NewSegmentModal {...defaultProps} />) - // Act fireEvent.change(screen.getByTestId('keywords-input'), { target: { value: 'keyword1,keyword2' }, }) - // Assert expect(screen.getByTestId('keywords-input')).toHaveValue('keyword1,keyword2') }) it('should handle image upload', () => { - // Arrange render(<NewSegmentModal {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('upload-image-btn')) // Assert - image uploader should be rendered @@ -490,14 +422,230 @@ describe('NewSegmentModal', () => { }) it('should maintain structure when rerendered with different docForm', () => { - // Arrange const { rerender } = render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) - // Act rerender(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) - // Assert expect(screen.getByTestId('answer-input')).toBeInTheDocument() }) }) + + describe('CustomButton in success notification', () => { + it('should call viewNewlyAddedChunk when custom button is clicked', async () => { + const mockViewNewlyAddedChunk = vi.fn() + mockNotify.mockImplementation(() => {}) + + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render( + <NewSegmentModal + {...defaultProps} + docForm={ChunkingMode.text} + viewNewlyAddedChunk={mockViewNewlyAddedChunk} + />, + ) + + // Enter content and save + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Test content' } }) + fireEvent.click(screen.getByTestId('save-btn')) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + customComponent: expect.anything(), + }), + ) + }) + + // Extract customComponent from the notify call args + const notifyCallArgs = mockNotify.mock.calls[0][0] as { customComponent?: React.ReactElement } + expect(notifyCallArgs.customComponent).toBeDefined() + const customComponent = notifyCallArgs.customComponent! + const { container: btnContainer } = render(customComponent) + const viewButton = btnContainer.querySelector('.system-xs-semibold.text-text-accent') as HTMLElement + expect(viewButton).toBeInTheDocument() + fireEvent.click(viewButton) + + // Assert that viewNewlyAddedChunk was called via the onClick handler (lines 66-67) + expect(mockViewNewlyAddedChunk).toHaveBeenCalled() + }) + }) + + describe('QA mode save with content', () => { + it('should save with both question and answer in QA mode', async () => { + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) + + // Enter question and answer + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'My Question' } }) + fireEvent.change(screen.getByTestId('answer-input'), { target: { value: 'My Answer' } }) + + // Act - save + fireEvent.click(screen.getByTestId('save-btn')) + + // Assert - should call addSegment with both content and answer + await waitFor(() => { + expect(mockAddSegment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + content: 'My Question', + answer: 'My Answer', + }), + }), + expect.any(Object), + ) + }) + }) + }) + + describe('Keywords in save params', () => { + it('should include keywords in save params when keywords are provided', async () => { + mockIndexingTechnique = IndexingType.ECONOMICAL + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) + + // Enter content + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Content with keywords' } }) + // Enter keywords + fireEvent.change(screen.getByTestId('keywords-input'), { target: { value: 'kw1,kw2' } }) + + // Act - save + fireEvent.click(screen.getByTestId('save-btn')) + + await waitFor(() => { + expect(mockAddSegment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + content: 'Content with keywords', + keywords: ['kw1', 'kw2'], + }), + }), + expect.any(Object), + ) + }) + }) + }) + + describe('Save with attachments', () => { + it('should include attachment_ids in save params when images are uploaded', async () => { + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) + + // Enter content + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Content with images' } }) + // Upload an image + fireEvent.click(screen.getByTestId('upload-image-btn')) + + // Act - save + fireEvent.click(screen.getByTestId('save-btn')) + + await waitFor(() => { + expect(mockAddSegment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + content: 'Content with images', + attachment_ids: ['img-1'], + }), + }), + expect.any(Object), + ) + }) + }) + }) + + describe('handleCancel with addAnother unchecked', () => { + it('should call onCancel when addAnother is unchecked and save succeeds', async () => { + const mockOnCancel = vi.fn() + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render(<NewSegmentModal {...defaultProps} onCancel={mockOnCancel} docForm={ChunkingMode.text} />) + + // Uncheck "add another" + const checkbox = screen.getByTestId('add-another-checkbox') + fireEvent.click(checkbox) + + // Enter content and save + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Test' } }) + fireEvent.click(screen.getByTestId('save-btn')) + + // Assert - should call onCancel since addAnother is false + await waitFor(() => { + expect(mockOnCancel).toHaveBeenCalled() + }) + }) + }) + + describe('onSave delayed call', () => { + it('should call onSave after timeout in success handler', async () => { + vi.useFakeTimers() + const mockOnSave = vi.fn() + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render(<NewSegmentModal {...defaultProps} onSave={mockOnSave} docForm={ChunkingMode.text} />) + + // Enter content and save + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Test content' } }) + fireEvent.click(screen.getByTestId('save-btn')) + + // Fast-forward timer + vi.advanceTimersByTime(3000) + + expect(mockOnSave).toHaveBeenCalled() + vi.useRealTimers() + }) + }) + + describe('Word count display', () => { + it('should display character count for QA mode (question + answer)', () => { + render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) + + // Enter question and answer + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'abc' } }) + fireEvent.change(screen.getByTestId('answer-input'), { target: { value: 'de' } }) + + // Assert - should show count of 5 (3 + 2) + // The component uses formatNumber and shows "X characters" + expect(screen.getByText(/5/)).toBeInTheDocument() + }) + }) + + describe('Non-fullscreen footer', () => { + it('should render footer with AddAnother and ActionButtons when not in fullScreen', () => { + mockFullScreen = false + + render(<NewSegmentModal {...defaultProps} />) + + // Assert - footer should have both AddAnother and ActionButtons + expect(screen.getByTestId('add-another')).toBeInTheDocument() + expect(screen.getByTestId('action-buttons')).toBeInTheDocument() + }) + }) }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-downloader.spec.tsx similarity index 91% rename from web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx rename to web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-downloader.spec.tsx index 3326a36aa0..52353b856a 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-downloader.spec.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { LanguagesSupported } from '@/i18n-config/language' import { ChunkingMode } from '@/models/datasets' -import CSVDownload from './csv-downloader' +import CSVDownload from '../csv-downloader' // Mock useLocale let mockLocale = LanguagesSupported[0] // en-US @@ -37,18 +37,14 @@ describe('CSVDownloader', () => { mockLocale = LanguagesSupported[0] // Reset to English }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render structure title', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) // Assert - i18n key format @@ -56,10 +52,8 @@ describe('CSVDownloader', () => { }) it('should render download template link', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert expect(screen.getByTestId('csv-downloader-link')).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.template/i)).toBeInTheDocument() }) @@ -68,7 +62,6 @@ describe('CSVDownloader', () => { // Table structure for QA mode describe('QA Mode Table', () => { it('should render QA table with question and answer columns when docForm is qa', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.qa} />) // Assert - Check for question/answer headers @@ -80,10 +73,8 @@ describe('CSVDownloader', () => { }) it('should render two data rows for QA mode', () => { - // Arrange & Act const { container } = render(<CSVDownload docForm={ChunkingMode.qa} />) - // Assert const tbody = container.querySelector('tbody') expect(tbody).toBeInTheDocument() const rows = tbody?.querySelectorAll('tr') @@ -94,7 +85,6 @@ describe('CSVDownloader', () => { // Table structure for Text mode describe('Text Mode Table', () => { it('should render text table with content column when docForm is text', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) // Assert - Check for content header @@ -102,19 +92,15 @@ describe('CSVDownloader', () => { }) it('should not render question/answer columns in text mode', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert expect(screen.queryByText(/list\.batchModal\.question/i)).not.toBeInTheDocument() expect(screen.queryByText(/list\.batchModal\.answer/i)).not.toBeInTheDocument() }) it('should render two data rows for text mode', () => { - // Arrange & Act const { container } = render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert const tbody = container.querySelector('tbody') expect(tbody).toBeInTheDocument() const rows = tbody?.querySelectorAll('tr') @@ -125,13 +111,10 @@ describe('CSVDownloader', () => { // CSV Template Data describe('CSV Template Data', () => { it('should provide English QA template when locale is English and docForm is qa', () => { - // Arrange mockLocale = LanguagesSupported[0] // en-US - // Act render(<CSVDownload docForm={ChunkingMode.qa} />) - // Assert const link = screen.getByTestId('csv-downloader-link') const data = JSON.parse(link.getAttribute('data-data') || '[]') expect(data).toEqual([ @@ -142,13 +125,10 @@ describe('CSVDownloader', () => { }) it('should provide English text template when locale is English and docForm is text', () => { - // Arrange mockLocale = LanguagesSupported[0] // en-US - // Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert const link = screen.getByTestId('csv-downloader-link') const data = JSON.parse(link.getAttribute('data-data') || '[]') expect(data).toEqual([ @@ -159,13 +139,10 @@ describe('CSVDownloader', () => { }) it('should provide Chinese QA template when locale is Chinese and docForm is qa', () => { - // Arrange mockLocale = LanguagesSupported[1] // zh-Hans - // Act render(<CSVDownload docForm={ChunkingMode.qa} />) - // Assert const link = screen.getByTestId('csv-downloader-link') const data = JSON.parse(link.getAttribute('data-data') || '[]') expect(data).toEqual([ @@ -176,13 +153,10 @@ describe('CSVDownloader', () => { }) it('should provide Chinese text template when locale is Chinese and docForm is text', () => { - // Arrange mockLocale = LanguagesSupported[1] // zh-Hans - // Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert const link = screen.getByTestId('csv-downloader-link') const data = JSON.parse(link.getAttribute('data-data') || '[]') expect(data).toEqual([ @@ -196,31 +170,24 @@ describe('CSVDownloader', () => { // CSVDownloader props describe('CSVDownloader Props', () => { it('should set filename to template', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert const link = screen.getByTestId('csv-downloader-link') expect(link.getAttribute('data-filename')).toBe('template') }) it('should set type to Link', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert const link = screen.getByTestId('csv-downloader-link') expect(link.getAttribute('data-type')).toBe('link') }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered with different docForm', () => { - // Arrange const { rerender } = render(<CSVDownload docForm={ChunkingMode.text} />) - // Act rerender(<CSVDownload docForm={ChunkingMode.qa} />) // Assert - should now show QA table @@ -228,10 +195,8 @@ describe('CSVDownloader', () => { }) it('should render correctly for non-English locales', () => { - // Arrange mockLocale = LanguagesSupported[1] // zh-Hans - // Act render(<CSVDownload docForm={ChunkingMode.qa} />) // Assert - Check that Chinese template is used diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx similarity index 68% rename from web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx rename to web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx index 54001c8736..7fb1de7cf9 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' import type { CustomFile, FileItem } from '@/models/datasets' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Theme } from '@/types/app' -import CSVUploader from './csv-uploader' +import CSVUploader from '../csv-uploader' // Mock upload service const mockUpload = vi.fn() @@ -24,7 +24,6 @@ vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: Theme.light }), })) -// Mock ToastContext const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ ToastContext: { @@ -52,40 +51,31 @@ describe('CSVUploader', () => { updateFile: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<CSVUploader {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render upload area when no file is present', () => { - // Arrange & Act render(<CSVUploader {...defaultProps} />) - // Assert expect(screen.getByText(/list\.batchModal\.csvUploadTitle/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.browse/i)).toBeInTheDocument() }) it('should render hidden file input', () => { - // Arrange & Act const { container } = render(<CSVUploader {...defaultProps} />) - // Assert const fileInput = container.querySelector('input[type="file"]') expect(fileInput).toBeInTheDocument() expect(fileInput).toHaveStyle({ display: 'none' }) }) it('should accept only CSV files', () => { - // Arrange & Act const { container } = render(<CSVUploader {...defaultProps} />) - // Assert const fileInput = container.querySelector('input[type="file"]') expect(fileInput).toHaveAttribute('accept', '.csv') }) @@ -94,69 +84,55 @@ describe('CSVUploader', () => { // File display tests describe('File Display', () => { it('should display file info when file is present', () => { - // Arrange const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'test-file.csv', { type: 'text/csv' }) as CustomFile, progress: 100, } - // Act render(<CSVUploader {...defaultProps} file={mockFile} />) - // Assert expect(screen.getByText('test-file')).toBeInTheDocument() expect(screen.getByText('.csv')).toBeInTheDocument() }) it('should not show upload area when file is present', () => { - // Arrange const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, progress: 100, } - // Act render(<CSVUploader {...defaultProps} file={mockFile} />) - // Assert expect(screen.queryByText(/list\.batchModal\.csvUploadTitle/i)).not.toBeInTheDocument() }) it('should show change button when file is present', () => { - // Arrange const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, progress: 100, } - // Act render(<CSVUploader {...defaultProps} file={mockFile} />) - // Assert expect(screen.getByText(/stepOne\.uploader\.change/i)).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should trigger file input click when browse is clicked', () => { - // Arrange const { container } = render(<CSVUploader {...defaultProps} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const clickSpy = vi.spyOn(fileInput, 'click') - // Act fireEvent.click(screen.getByText(/list\.batchModal\.browse/i)) - // Assert expect(clickSpy).toHaveBeenCalled() }) it('should call updateFile when file is selected', async () => { - // Arrange const mockUpdateFile = vi.fn() mockUpload.mockResolvedValueOnce({ id: 'uploaded-id' }) @@ -166,17 +142,14 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert await waitFor(() => { expect(mockUpdateFile).toHaveBeenCalled() }) }) it('should call updateFile with undefined when remove is clicked', () => { - // Arrange const mockUpdateFile = vi.fn() const mockFile: FileItem = { fileID: 'file-1', @@ -187,28 +160,22 @@ describe('CSVUploader', () => { <CSVUploader {...defaultProps} file={mockFile} updateFile={mockUpdateFile} />, ) - // Act const deleteButton = container.querySelector('.cursor-pointer') if (deleteButton) fireEvent.click(deleteButton) - // Assert expect(mockUpdateFile).toHaveBeenCalledWith() }) }) - // Validation tests describe('Validation', () => { it('should show error for non-CSV files', () => { - // Arrange const { container } = render(<CSVUploader {...defaultProps} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', @@ -217,7 +184,6 @@ describe('CSVUploader', () => { }) it('should show error for files exceeding size limit', () => { - // Arrange const { container } = render(<CSVUploader {...defaultProps} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement @@ -225,10 +191,8 @@ describe('CSVUploader', () => { const testFile = new File(['test'], 'large.csv', { type: 'text/csv' }) Object.defineProperty(testFile, 'size', { value: 16 * 1024 * 1024 }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', @@ -240,14 +204,12 @@ describe('CSVUploader', () => { // Upload progress tests describe('Upload Progress', () => { it('should show progress indicator when upload is in progress', () => { - // Arrange const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, progress: 50, } - // Act const { container } = render(<CSVUploader {...defaultProps} file={mockFile} />) // Assert - SimplePieChart should be rendered for progress 0-99 @@ -256,14 +218,12 @@ describe('CSVUploader', () => { }) it('should not show progress for completed uploads', () => { - // Arrange const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, progress: 100, } - // Act render(<CSVUploader {...defaultProps} file={mockFile} />) // Assert - File name should be displayed @@ -271,10 +231,8 @@ describe('CSVUploader', () => { }) }) - // Props tests describe('Props', () => { it('should call updateFile prop when provided', async () => { - // Arrange const mockUpdateFile = vi.fn() mockUpload.mockResolvedValueOnce({ id: 'test-id' }) @@ -284,53 +242,42 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert await waitFor(() => { expect(mockUpdateFile).toHaveBeenCalled() }) }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty file list', () => { - // Arrange const mockUpdateFile = vi.fn() const { container } = render( <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, ) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement - // Act fireEvent.change(fileInput, { target: { files: [] } }) - // Assert expect(mockUpdateFile).not.toHaveBeenCalled() }) it('should handle null file', () => { - // Arrange const mockUpdateFile = vi.fn() const { container } = render( <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, ) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement - // Act fireEvent.change(fileInput, { target: { files: null } }) - // Assert expect(mockUpdateFile).not.toHaveBeenCalled() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<CSVUploader {...defaultProps} />) - // Act const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'updated.csv', { type: 'text/csv' }) as CustomFile, @@ -338,12 +285,10 @@ describe('CSVUploader', () => { } rerender(<CSVUploader {...defaultProps} file={mockFile} />) - // Assert expect(screen.getByText('updated')).toBeInTheDocument() }) it('should handle upload error', async () => { - // Arrange const mockUpdateFile = vi.fn() mockUpload.mockRejectedValueOnce(new Error('Upload failed')) @@ -353,10 +298,8 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -367,15 +310,12 @@ describe('CSVUploader', () => { }) it('should handle file without extension', () => { - // Arrange const { container } = render(<CSVUploader {...defaultProps} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'noextension', { type: 'text/plain' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', @@ -389,7 +329,6 @@ describe('CSVUploader', () => { // Testing these requires triggering native DOM events on the actual dropRef element. describe('Drag and Drop', () => { it('should render drop zone element', () => { - // Arrange & Act const { container } = render(<CSVUploader {...defaultProps} />) // Assert - drop zone should exist for drag and drop @@ -398,7 +337,6 @@ describe('CSVUploader', () => { }) it('should have drag overlay element that can appear during drag', () => { - // Arrange & Act const { container } = render(<CSVUploader {...defaultProps} />) // Assert - component structure supports dragging @@ -409,7 +347,6 @@ describe('CSVUploader', () => { // Upload progress callback tests describe('Upload Progress Callbacks', () => { it('should update progress during file upload', async () => { - // Arrange const mockUpdateFile = vi.fn() let progressCallback: ((e: ProgressEvent) => void) | undefined @@ -424,7 +361,6 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) // Simulate progress event @@ -437,7 +373,6 @@ describe('CSVUploader', () => { progressCallback(progressEvent) } - // Assert await waitFor(() => { expect(mockUpdateFile).toHaveBeenCalledWith( expect.objectContaining({ @@ -448,7 +383,6 @@ describe('CSVUploader', () => { }) it('should handle progress event with lengthComputable false', async () => { - // Arrange const mockUpdateFile = vi.fn() let progressCallback: ((e: ProgressEvent) => void) | undefined @@ -463,7 +397,6 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) // Simulate progress event with lengthComputable false @@ -482,4 +415,174 @@ describe('CSVUploader', () => { }) }) }) + + describe('Drag and Drop Events', () => { + // Helper to get the dropRef element (sibling of the hidden file input) + const getDropZone = (container: HTMLElement) => { + const fileInput = container.querySelector('input[type="file"]') + return fileInput?.nextElementSibling as HTMLElement + } + + it('should handle dragenter event and set dragging state', async () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const dropZone = getDropZone(container) + + // Act - dispatch dragenter event wrapped in act to avoid state update warning + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + Object.defineProperty(dragEnterEvent, 'target', { value: dropZone }) + act(() => { + dropZone.dispatchEvent(dragEnterEvent) + }) + + // Assert - dragging class should be applied (border style changes) + await waitFor(() => { + const uploadArea = container.querySelector('.border-dashed') + expect(uploadArea || dropZone).toBeInTheDocument() + }) + }) + + it('should handle dragover event', () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const dropZone = getDropZone(container) + + const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true }) + dropZone.dispatchEvent(dragOverEvent) + + // Assert - no error thrown + expect(dropZone).toBeInTheDocument() + }) + + it('should handle dragleave event', () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const dropZone = getDropZone(container) + + // First set dragging to true + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + act(() => { + dropZone.dispatchEvent(dragEnterEvent) + }) + + // Act - dispatch dragleave + const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true }) + act(() => { + dropZone.dispatchEvent(dragLeaveEvent) + }) + + expect(dropZone).toBeInTheDocument() + }) + + it('should set dragging to false when dragleave target is the drag overlay', async () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const dropZone = getDropZone(container) + + // Trigger dragenter to set dragging=true, which renders the overlay + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + act(() => { + dropZone.dispatchEvent(dragEnterEvent) + }) + + // Find the drag overlay element (rendered only when dragging=true) + await waitFor(() => { + expect(container.querySelector('.absolute.left-0.top-0')).toBeInTheDocument() + }) + const dragOverlay = container.querySelector('.absolute.left-0.top-0') as HTMLElement + + // Act - dispatch dragleave FROM the overlay so e.target === dragRef.current (line 121) + const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true }) + Object.defineProperty(dragLeaveEvent, 'target', { value: dragOverlay }) + act(() => { + dropZone.dispatchEvent(dragLeaveEvent) + }) + + // Assert - dragging should be set to false, overlay should disappear + await waitFor(() => { + expect(container.querySelector('.absolute.left-0.top-0')).not.toBeInTheDocument() + }) + }) + + it('should handle drop event with valid CSV file', async () => { + const mockUpdateFile = vi.fn() + mockUpload.mockResolvedValueOnce({ id: 'dropped-file-id' }) + + const { container } = render( + <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, + ) + const dropZone = getDropZone(container) + + // Create a drop event with a CSV file + const testFile = new File(['csv,data'], 'dropped.csv', { type: 'text/csv' }) + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as unknown as DragEvent + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + files: [testFile], + }, + }) + + act(() => { + dropZone.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + expect(mockUpdateFile).toHaveBeenCalled() + }) + }) + + it('should show error when dropping multiple files', () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const dropZone = getDropZone(container) + + // Create a drop event with multiple files + const file1 = new File(['csv1'], 'file1.csv', { type: 'text/csv' }) + const file2 = new File(['csv2'], 'file2.csv', { type: 'text/csv' }) + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as unknown as DragEvent + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + files: [file1, file2], + }, + }) + + act(() => { + dropZone.dispatchEvent(dropEvent) + }) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) + }) + + it('should handle drop event without dataTransfer', () => { + const mockUpdateFile = vi.fn() + const { container } = render( + <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, + ) + const dropZone = getDropZone(container) + + // Create a drop event without dataTransfer + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + + act(() => { + dropZone.dispatchEvent(dropEvent) + }) + + // Assert - should not call updateFile + expect(mockUpdateFile).not.toHaveBeenCalled() + }) + }) + + describe('getFileType edge cases', () => { + it('should handle file with multiple dots in name', () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement + const testFile = new File(['content'], 'my.data.file.csv', { type: 'text/csv' }) + + fireEvent.change(fileInput, { target: { files: [testFile] } }) + + // Assert - should be valid and trigger upload + expect(mockNotify).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx rename to web/app/components/datasets/documents/detail/batch-modal/__tests__/index.spec.tsx index c056770158..11fa4bca38 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/index.spec.tsx @@ -2,10 +2,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import BatchModal from './index' +import BatchModal from '../index' -// Mock child components -vi.mock('./csv-downloader', () => ({ +vi.mock('../csv-downloader', () => ({ default: ({ docForm }: { docForm: ChunkingMode }) => ( <div data-testid="csv-downloader" data-doc-form={docForm}> CSV Downloader @@ -13,7 +12,7 @@ vi.mock('./csv-downloader', () => ({ ), })) -vi.mock('./csv-uploader', () => ({ +vi.mock('../csv-uploader', () => ({ default: ({ file, updateFile }: { file: { file?: { id: string } } | undefined, updateFile: (file: { file: { id: string } } | undefined) => void }) => ( <div data-testid="csv-uploader"> <button @@ -45,18 +44,14 @@ describe('BatchModal', () => { onConfirm: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing when isShow is true', () => { - // Arrange & Act render(<BatchModal {...defaultProps} />) - // Assert expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument() }) it('should not render content when isShow is false', () => { - // Arrange & Act render(<BatchModal {...defaultProps} isShow={false} />) // Assert - Modal is closed @@ -64,62 +59,47 @@ describe('BatchModal', () => { }) it('should render CSVDownloader component', () => { - // Arrange & Act render(<BatchModal {...defaultProps} />) - // Assert expect(screen.getByTestId('csv-downloader')).toBeInTheDocument() }) it('should render CSVUploader component', () => { - // Arrange & Act render(<BatchModal {...defaultProps} />) - // Assert expect(screen.getByTestId('csv-uploader')).toBeInTheDocument() }) it('should render cancel and run buttons', () => { - // Arrange & Act render(<BatchModal {...defaultProps} />) - // Assert expect(screen.getByText(/list\.batchModal\.cancel/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.run/i)).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when cancel button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() render(<BatchModal {...defaultProps} onCancel={mockOnCancel} />) - // Act fireEvent.click(screen.getByText(/list\.batchModal\.cancel/i)) - // Assert expect(mockOnCancel).toHaveBeenCalledTimes(1) }) it('should disable run button when no file is uploaded', () => { - // Arrange & Act render(<BatchModal {...defaultProps} />) - // Assert const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button') expect(runButton).toBeDisabled() }) it('should enable run button after file is uploaded', async () => { - // Arrange render(<BatchModal {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('upload-btn')) - // Assert await waitFor(() => { const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button') expect(runButton).not.toBeDisabled() @@ -127,7 +107,6 @@ describe('BatchModal', () => { }) it('should call onConfirm with file when run button is clicked', async () => { - // Arrange const mockOnConfirm = vi.fn() const mockOnCancel = vi.fn() render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} onCancel={mockOnCancel} />) @@ -143,19 +122,15 @@ describe('BatchModal', () => { // Act - click run fireEvent.click(screen.getByText(/list\.batchModal\.run/i)) - // Assert expect(mockOnCancel).toHaveBeenCalledTimes(1) expect(mockOnConfirm).toHaveBeenCalledWith({ file: { id: 'test-file-id' } }) }) }) - // Props tests describe('Props', () => { it('should pass docForm to CSVDownloader', () => { - // Arrange & Act render(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />) - // Assert expect(screen.getByTestId('csv-downloader').getAttribute('data-doc-form')).toBe(ChunkingMode.qa) }) }) @@ -163,7 +138,6 @@ describe('BatchModal', () => { // State reset tests describe('State Reset', () => { it('should reset file when modal is closed and reopened', async () => { - // Arrange const { rerender } = render(<BatchModal {...defaultProps} />) // Upload a file @@ -172,7 +146,6 @@ describe('BatchModal', () => { expect(screen.getByTestId('file-info')).toBeInTheDocument() }) - // Close modal rerender(<BatchModal {...defaultProps} isShow={false} />) // Reopen modal @@ -183,10 +156,8 @@ describe('BatchModal', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should not call onConfirm when no file is present', () => { - // Arrange const mockOnConfirm = vi.fn() render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} />) @@ -195,23 +166,18 @@ describe('BatchModal', () => { if (runButton) fireEvent.click(runButton) - // Assert expect(mockOnConfirm).not.toHaveBeenCalled() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<BatchModal {...defaultProps} />) - // Act rerender(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />) - // Assert expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument() }) it('should handle file cleared after upload', async () => { - // Arrange const mockOnConfirm = vi.fn() render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} />) diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx index 7709c15058..4e3c7acd2b 100644 --- a/web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx @@ -2,12 +2,12 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import ChildSegmentDetail from './child-segment-detail' +import ChildSegmentDetail from '../child-segment-detail' // Mock segment list context let mockFullScreen = false const mockToggleFullScreen = vi.fn() -vi.mock('./index', () => ({ +vi.mock('../index', () => ({ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { const state = { fullScreen: mockFullScreen, @@ -29,8 +29,7 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -// Mock child components -vi.mock('./common/action-buttons', () => ({ +vi.mock('../common/action-buttons', () => ({ default: ({ handleCancel, handleSave, loading, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, isChildChunk?: boolean }) => ( <div data-testid="action-buttons"> <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> @@ -40,7 +39,7 @@ vi.mock('./common/action-buttons', () => ({ ), })) -vi.mock('./common/chunk-content', () => ({ +vi.mock('../common/chunk-content', () => ({ default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => ( <div data-testid="chunk-content"> <input @@ -53,11 +52,11 @@ vi.mock('./common/chunk-content', () => ({ ), })) -vi.mock('./common/dot', () => ({ +vi.mock('../common/dot', () => ({ default: () => <span data-testid="dot">‱</span>, })) -vi.mock('./common/segment-index-tag', () => ({ +vi.mock('../common/segment-index-tag', () => ({ SegmentIndexTag: ({ positionId, labelPrefix }: { positionId?: string, labelPrefix?: string }) => ( <span data-testid="segment-index-tag"> {labelPrefix} @@ -89,97 +88,74 @@ describe('ChildSegmentDetail', () => { docForm: ChunkingMode.text, } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render edit child chunk title', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.editChildChunk/i)).toBeInTheDocument() }) it('should render chunk content component', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('chunk-content')).toBeInTheDocument() }) it('should render segment index tag', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument() }) it('should render word count', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.characters/i)).toBeInTheDocument() }) it('should render edit time', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.editedAt/i)).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() const { container } = render( <ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />, ) - // Act const closeButtons = container.querySelectorAll('.cursor-pointer') if (closeButtons.length > 1) fireEvent.click(closeButtons[1]) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - // Arrange const { container } = render(<ChildSegmentDetail {...defaultProps} />) - // Act const expandButtons = container.querySelectorAll('.cursor-pointer') if (expandButtons.length > 0) fireEvent.click(expandButtons[0]) - // Assert expect(mockToggleFullScreen).toHaveBeenCalled() }) it('should call onUpdate when save is clicked', () => { - // Arrange const mockOnUpdate = vi.fn() render(<ChildSegmentDetail {...defaultProps} onUpdate={mockOnUpdate} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith( 'chunk-1', 'child-chunk-1', @@ -188,15 +164,12 @@ describe('ChildSegmentDetail', () => { }) it('should update content when input changes', () => { - // Arrange render(<ChildSegmentDetail {...defaultProps} />) - // Act fireEvent.change(screen.getByTestId('content-input'), { target: { value: 'Updated content' }, }) - // Assert expect(screen.getByTestId('content-input')).toHaveValue('Updated content') }) }) @@ -204,21 +177,16 @@ describe('ChildSegmentDetail', () => { // Full screen mode describe('Full Screen Mode', () => { it('should show action buttons in header when fullScreen is true', () => { - // Arrange mockFullScreen = true - // Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) it('should not show footer action buttons when fullScreen is true', () => { - // Arrange mockFullScreen = true - // Act render(<ChildSegmentDetail {...defaultProps} />) // Assert - footer with border-t-divider-subtle should not exist @@ -228,13 +196,10 @@ describe('ChildSegmentDetail', () => { }) it('should show footer action buttons when fullScreen is false', () => { - // Arrange mockFullScreen = false - // Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) }) @@ -242,54 +207,41 @@ describe('ChildSegmentDetail', () => { // Props describe('Props', () => { it('should pass isChildChunk true to ActionButtons', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true') }) it('should pass isEditMode true to ChunkContent', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined childChunkInfo', () => { - // Arrange & Act const { container } = render( <ChildSegmentDetail {...defaultProps} childChunkInfo={undefined} />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle empty content', () => { - // Arrange const emptyChildChunkInfo = { ...defaultChildChunkInfo, content: '' } - // Act render(<ChildSegmentDetail {...defaultProps} childChunkInfo={emptyChildChunkInfo} />) - // Assert expect(screen.getByTestId('content-input')).toHaveValue('') }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<ChildSegmentDetail {...defaultProps} />) - // Act const updatedInfo = { ...defaultChildChunkInfo, content: 'New content' } rerender(<ChildSegmentDetail {...defaultProps} childChunkInfo={updatedInfo} />) - // Assert expect(screen.getByTestId('content-input')).toBeInTheDocument() }) }) @@ -297,7 +249,6 @@ describe('ChildSegmentDetail', () => { // Event subscription tests describe('Event Subscription', () => { it('should register event subscription', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) // Assert - subscription callback should be registered @@ -305,7 +256,6 @@ describe('ChildSegmentDetail', () => { }) it('should have save button enabled by default', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) // Assert - save button should be enabled initially @@ -316,14 +266,11 @@ describe('ChildSegmentDetail', () => { // Cancel behavior describe('Cancel Behavior', () => { it('should call onCancel when cancel button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() render(<ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />) - // Act fireEvent.click(screen.getByTestId('cancel-btn')) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-list.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-list.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/detail/completed/child-segment-list.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/child-segment-list.spec.tsx index f63910fccd..11ced823da 100644 --- a/web/app/components/datasets/documents/detail/completed/child-segment-list.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-list.spec.tsx @@ -2,11 +2,11 @@ import type { ChildChunkDetail } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import ChildSegmentList from './child-segment-list' +import ChildSegmentList from '../child-segment-list' // Mock document context let mockParentMode = 'paragraph' -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => { return selector({ parentMode: mockParentMode }) }, @@ -14,14 +14,13 @@ vi.mock('../context', () => ({ // Mock segment list context let mockCurrChildChunk: { childChunkInfo: { id: string } } | null = null -vi.mock('./index', () => ({ +vi.mock('../index', () => ({ useSegmentListContext: (selector: (state: { currChildChunk: { childChunkInfo: { id: string } } | null }) => unknown) => { return selector({ currChildChunk: mockCurrChildChunk }) }, })) -// Mock child components -vi.mock('./common/empty', () => ({ +vi.mock('../common/empty', () => ({ default: ({ onClearFilter }: { onClearFilter: () => void }) => ( <div data-testid="empty"> <button onClick={onClearFilter} data-testid="clear-filter-btn">Clear Filter</button> @@ -29,11 +28,11 @@ vi.mock('./common/empty', () => ({ ), })) -vi.mock('./skeleton/full-doc-list-skeleton', () => ({ +vi.mock('../skeleton/full-doc-list-skeleton', () => ({ default: () => <div data-testid="full-doc-skeleton">Loading...</div>, })) -vi.mock('../../../formatted-text/flavours/edit-slice', () => ({ +vi.mock('../../../../formatted-text/flavours/edit-slice', () => ({ EditSlice: ({ label, text, @@ -62,7 +61,7 @@ vi.mock('../../../formatted-text/flavours/edit-slice', () => ({ ), })) -vi.mock('../../../formatted-text/formatted', () => ({ +vi.mock('../../../../formatted-text/formatted', () => ({ FormattedText: ({ children, className }: { children: React.ReactNode, className: string }) => ( <div data-testid="formatted-text" className={className}>{children}</div> ), @@ -101,29 +100,22 @@ describe('ChildSegmentList', () => { focused: false, } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<ChildSegmentList {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render total count text', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.childChunks/i)).toBeInTheDocument() }) it('should render add button', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} />) - // Assert expect(screen.getByText(/operation\.add/i)).toBeInTheDocument() }) }) @@ -135,7 +127,6 @@ describe('ChildSegmentList', () => { }) it('should render collapsed by default in paragraph mode', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} />) // Assert - collapsed icon should be present @@ -143,7 +134,6 @@ describe('ChildSegmentList', () => { }) it('should expand when clicking toggle in paragraph mode', () => { - // Arrange render(<ChildSegmentList {...defaultProps} />) // Act - click on the collapse toggle @@ -156,7 +146,6 @@ describe('ChildSegmentList', () => { }) it('should collapse when clicking toggle again', () => { - // Arrange render(<ChildSegmentList {...defaultProps} />) // Act - click twice @@ -178,61 +167,47 @@ describe('ChildSegmentList', () => { }) it('should render input field in full-doc mode', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} />) - // Assert const input = screen.getByRole('textbox') expect(input).toBeInTheDocument() }) it('should render child chunks without collapse in full-doc mode', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} />) - // Assert expect(screen.getByTestId('formatted-text')).toBeInTheDocument() }) it('should call handleInputChange when input changes', () => { - // Arrange const mockHandleInputChange = vi.fn() render(<ChildSegmentList {...defaultProps} handleInputChange={mockHandleInputChange} />) - // Act const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'search term' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledWith('search term') }) it('should show search results text when searching', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} inputValue="search" total={5} />) - // Assert expect(screen.getByText(/segment\.searchResults/i)).toBeInTheDocument() }) it('should show empty component when no results and searching', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} total={0} />) - // Assert expect(screen.getByTestId('empty')).toBeInTheDocument() }) it('should show loading skeleton when isLoading is true', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} isLoading={true} />) - // Assert expect(screen.getByTestId('full-doc-skeleton')).toBeInTheDocument() }) it('should handle undefined total in full-doc mode', () => { - // Arrange & Act const { container } = render(<ChildSegmentList {...defaultProps} total={undefined} />) // Assert - component should render without crashing @@ -240,57 +215,44 @@ describe('ChildSegmentList', () => { }) }) - // User Interactions describe('User Interactions', () => { it('should call handleAddNewChildChunk when add button is clicked', () => { - // Arrange mockParentMode = 'full-doc' const mockHandleAddNewChildChunk = vi.fn() render(<ChildSegmentList {...defaultProps} handleAddNewChildChunk={mockHandleAddNewChildChunk} />) - // Act fireEvent.click(screen.getByText(/operation\.add/i)) - // Assert expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('parent-1') }) it('should call onDelete when delete button is clicked', () => { - // Arrange mockParentMode = 'full-doc' const mockOnDelete = vi.fn() render(<ChildSegmentList {...defaultProps} onDelete={mockOnDelete} />) - // Act fireEvent.click(screen.getByTestId('delete-slice-btn')) - // Assert expect(mockOnDelete).toHaveBeenCalledWith('seg-1', 'child-1') }) it('should call onClickSlice when slice is clicked', () => { - // Arrange mockParentMode = 'full-doc' const mockOnClickSlice = vi.fn() render(<ChildSegmentList {...defaultProps} onClickSlice={mockOnClickSlice} />) - // Act fireEvent.click(screen.getByTestId('click-slice-btn')) - // Assert expect(mockOnClickSlice).toHaveBeenCalledWith(expect.objectContaining({ id: 'child-1' })) }) it('should call onClearFilter when clear filter button is clicked', () => { - // Arrange mockParentMode = 'full-doc' const mockOnClearFilter = vi.fn() render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} onClearFilter={mockOnClearFilter} />) - // Act fireEvent.click(screen.getByTestId('clear-filter-btn')) - // Assert expect(mockOnClearFilter).toHaveBeenCalled() }) }) @@ -298,11 +260,9 @@ describe('ChildSegmentList', () => { // Focused state describe('Focused State', () => { it('should apply focused style when currChildChunk matches', () => { - // Arrange mockParentMode = 'full-doc' mockCurrChildChunk = { childChunkInfo: { id: 'child-1' } } - // Act render(<ChildSegmentList {...defaultProps} />) // Assert - check for focused class on label @@ -311,14 +271,11 @@ describe('ChildSegmentList', () => { }) it('should not apply focused style when currChildChunk does not match', () => { - // Arrange mockParentMode = 'full-doc' mockCurrChildChunk = { childChunkInfo: { id: 'other-child' } } - // Act render(<ChildSegmentList {...defaultProps} />) - // Assert const label = screen.getByTestId('slice-label') expect(label).not.toHaveClass('bg-state-accent-solid') }) @@ -327,28 +284,22 @@ describe('ChildSegmentList', () => { // Enabled/Disabled state describe('Enabled State', () => { it('should apply opacity when enabled is false', () => { - // Arrange & Act const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('opacity-50') }) it('should not apply opacity when enabled is true', () => { - // Arrange & Act const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).not.toHaveClass('opacity-50') }) it('should not apply opacity when focused is true even if enabled is false', () => { - // Arrange & Act const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} focused={true} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).not.toHaveClass('opacity-50') }) @@ -357,14 +308,11 @@ describe('ChildSegmentList', () => { // Edited indicator describe('Edited Indicator', () => { it('should show edited indicator for edited chunks', () => { - // Arrange mockParentMode = 'full-doc' const editedChunk = createMockChildChunk('child-edited', 'Edited content', true) - // Act render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} />) - // Assert const label = screen.getByTestId('slice-label') expect(label.textContent).toContain('segment.edited') }) @@ -373,7 +321,6 @@ describe('ChildSegmentList', () => { // Multiple chunks describe('Multiple Chunks', () => { it('should render multiple child chunks', () => { - // Arrange mockParentMode = 'full-doc' const chunks = [ createMockChildChunk('child-1', 'Content 1'), @@ -381,48 +328,36 @@ describe('ChildSegmentList', () => { createMockChildChunk('child-3', 'Content 3'), ] - // Act render(<ChildSegmentList {...defaultProps} childChunks={chunks} total={3} />) - // Assert expect(screen.getAllByTestId('edit-slice')).toHaveLength(3) }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty childChunks array', () => { - // Arrange mockParentMode = 'full-doc' - // Act const { container } = render(<ChildSegmentList {...defaultProps} childChunks={[]} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange mockParentMode = 'full-doc' const { rerender } = render(<ChildSegmentList {...defaultProps} />) - // Act const newChunks = [createMockChildChunk('new-child', 'New content')] rerender(<ChildSegmentList {...defaultProps} childChunks={newChunks} />) - // Assert expect(screen.getByText('New content')).toBeInTheDocument() }) it('should disable add button when loading', () => { - // Arrange mockParentMode = 'full-doc' - // Act render(<ChildSegmentList {...defaultProps} isLoading={true} />) - // Assert const addButton = screen.getByText(/operation\.add/i) expect(addButton).toBeDisabled() }) diff --git a/web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/display-toggle.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/display-toggle.spec.tsx index e1004b1454..73d5ec920c 100644 --- a/web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/display-toggle.spec.tsx @@ -1,27 +1,22 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import DisplayToggle from './display-toggle' +import DisplayToggle from '../display-toggle' describe('DisplayToggle', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should render button with proper styling', () => { - // Arrange & Act render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('flex') expect(button).toHaveClass('items-center') @@ -30,10 +25,8 @@ describe('DisplayToggle', () => { }) }) - // Props tests describe('Props', () => { it('should render expand icon when isCollapsed is true', () => { - // Arrange & Act const { container } = render( <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, ) @@ -44,7 +37,6 @@ describe('DisplayToggle', () => { }) it('should render collapse icon when isCollapsed is false', () => { - // Arrange & Act const { container } = render( <DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />, ) @@ -55,32 +47,25 @@ describe('DisplayToggle', () => { }) }) - // User Interactions describe('User Interactions', () => { it('should call toggleCollapsed when button is clicked', () => { - // Arrange const mockToggle = vi.fn() render(<DisplayToggle isCollapsed={true} toggleCollapsed={mockToggle} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockToggle).toHaveBeenCalledTimes(1) }) it('should call toggleCollapsed on multiple clicks', () => { - // Arrange const mockToggle = vi.fn() render(<DisplayToggle isCollapsed={true} toggleCollapsed={mockToggle} />) - // Act const button = screen.getByRole('button') fireEvent.click(button) fireEvent.click(button) fireEvent.click(button) - // Assert expect(mockToggle).toHaveBeenCalledTimes(3) }) }) @@ -88,7 +73,6 @@ describe('DisplayToggle', () => { // Tooltip tests describe('Tooltip', () => { it('should render with tooltip wrapper', () => { - // Arrange & Act const { container } = render( <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, ) @@ -98,15 +82,12 @@ describe('DisplayToggle', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should toggle icon when isCollapsed prop changes', () => { - // Arrange const { rerender, container } = render( <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, ) - // Act rerender(<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />) // Assert - icon should still be present @@ -115,15 +96,12 @@ describe('DisplayToggle', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render( <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, ) - // Act rerender(<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx similarity index 51% rename from web/app/components/datasets/documents/detail/completed/index.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx index fabce2decf..5802fb8b82 100644 --- a/web/app/components/datasets/documents/detail/completed/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx @@ -1,18 +1,11 @@ import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' import type { ChildChunkDetail, ChunkingMode, ParentMode, SegmentDetailModel } from '@/models/datasets' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets' -import { useModalState } from './hooks/use-modal-state' -import { useSearchFilter } from './hooks/use-search-filter' -import { useSegmentSelection } from './hooks/use-segment-selection' -import Completed from './index' -import { SegmentListContext, useSegmentListContext } from './segment-list-context' - -// ============================================================================ -// Hoisted Mocks (must be before vi.mock calls) -// ============================================================================ +import Completed from '../index' +import { SegmentListContext, useSegmentListContext } from '../segment-list-context' const { mockDocForm, @@ -56,45 +49,11 @@ const { mockOnDelete: vi.fn(), })) -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { count?: number, ns?: string }) => { - if (key === 'segment.chunks') - return options?.count === 1 ? 'chunk' : 'chunks' - if (key === 'segment.parentChunks') - return options?.count === 1 ? 'parent chunk' : 'parent chunks' - if (key === 'segment.searchResults') - return 'search results' - if (key === 'list.index.all') - return 'All' - if (key === 'list.status.disabled') - return 'Disabled' - if (key === 'list.status.enabled') - return 'Enabled' - if (key === 'actionMsg.modifiedSuccessfully') - return 'Modified successfully' - if (key === 'actionMsg.modifiedUnsuccessfully') - return 'Modified unsuccessfully' - if (key === 'segment.contentEmpty') - return 'Content cannot be empty' - if (key === 'segment.questionEmpty') - return 'Question cannot be empty' - if (key === 'segment.answerEmpty') - return 'Answer cannot be empty' - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), -})) - -// Mock next/navigation vi.mock('next/navigation', () => ({ usePathname: () => '/datasets/test-dataset-id/documents/test-document-id', })) -// Mock document context -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { const value: DocumentContextValue = { datasetId: mockDatasetId.current, @@ -106,18 +65,15 @@ vi.mock('../context', () => ({ }, })) -// Mock toast context vi.mock('@/app/components/base/toast', () => ({ ToastContext: { Provider: ({ children }: { children: React.ReactNode }) => children, Consumer: () => null }, useToastContext: () => ({ notify: mockNotify }), })) -// Mock event emitter context vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }), })) -// Mock segment service hooks vi.mock('@/service/knowledge/use-segment', () => ({ useSegmentList: () => ({ isLoading: false, @@ -140,10 +96,8 @@ vi.mock('@/service/knowledge/use-segment', () => ({ useUpdateChildSegment: () => ({ mutateAsync: vi.fn() }), })) -// Mock useInvalid - return trackable functions based on key vi.mock('@/service/use-base', () => ({ useInvalid: (key: unknown[]) => { - // Return specific mock functions based on key to track calls const keyStr = JSON.stringify(key) if (keyStr.includes('"enabled":"all"')) return mockInvalidChunkListAll @@ -155,14 +109,9 @@ vi.mock('@/service/use-base', () => ({ }, })) -// Note: useSegmentSelection is NOT mocked globally to allow direct hook testing -// Batch action tests will use a different approach - -// Mock useChildSegmentData to capture refreshChunkListDataWithDetailChanged let capturedRefreshCallback: (() => void) | null = null -vi.mock('./hooks/use-child-segment-data', () => ({ +vi.mock('../hooks/use-child-segment-data', () => ({ useChildSegmentData: (options: { refreshChunkListDataWithDetailChanged?: () => void }) => { - // Capture the callback for later testing if (options.refreshChunkListDataWithDetailChanged) capturedRefreshCallback = options.refreshChunkListDataWithDetailChanged @@ -181,11 +130,8 @@ vi.mock('./hooks/use-child-segment-data', () => ({ }, })) -// Note: useSearchFilter is NOT mocked globally to allow direct hook testing -// Individual tests that need to control selectedStatus will use different approaches - // Mock child components to simplify testing -vi.mock('./components', () => ({ +vi.mock('../components', () => ({ MenuBar: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: { totalText: string onInputChange: (value: string) => void @@ -219,7 +165,7 @@ vi.mock('./components', () => ({ GeneralModeContent: () => <div data-testid="general-mode-content" />, })) -vi.mock('./common/batch-action', () => ({ +vi.mock('../common/batch-action', () => ({ default: ({ selectedIds, onCancel, onBatchEnable, onBatchDisable, onBatchDelete }: { selectedIds: string[] onCancel: () => void @@ -257,10 +203,6 @@ vi.mock('@/app/components/base/pagination', () => ({ ), })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createMockSegmentDetail = (overrides: Partial<SegmentDetailModel> = {}): SegmentDetailModel => ({ id: `segment-${Math.random().toString(36).substr(2, 9)}`, position: 1, @@ -289,7 +231,7 @@ const createMockSegmentDetail = (overrides: Partial<SegmentDetailModel> = {}): S ...overrides, }) -const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({ +const _createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({ id: `child-${Math.random().toString(36).substr(2, 9)}`, position: 1, segment_id: 'segment-1', @@ -301,10 +243,6 @@ const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildC ...overrides, }) -// ============================================================================ -// Test Utilities -// ============================================================================ - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -321,767 +259,6 @@ const createWrapper = () => { ) } -// ============================================================================ -// useSearchFilter Hook Tests -// ============================================================================ - -describe('useSearchFilter', () => { - const mockOnPageChange = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe('Initial State', () => { - it('should initialize with default values', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - expect(result.current.inputValue).toBe('') - expect(result.current.searchValue).toBe('') - expect(result.current.selectedStatus).toBe('all') - expect(result.current.selectDefaultValue).toBe('all') - }) - - it('should have status list with all options', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - expect(result.current.statusList).toHaveLength(3) - expect(result.current.statusList[0].value).toBe('all') - expect(result.current.statusList[1].value).toBe(0) - expect(result.current.statusList[2].value).toBe(1) - }) - }) - - describe('handleInputChange', () => { - it('should update inputValue immediately', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.handleInputChange('test') - }) - - expect(result.current.inputValue).toBe('test') - }) - - it('should update searchValue after debounce', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.handleInputChange('test') - }) - - expect(result.current.searchValue).toBe('') - - act(() => { - vi.advanceTimersByTime(500) - }) - - expect(result.current.searchValue).toBe('test') - }) - - it('should call onPageChange(1) after debounce', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.handleInputChange('test') - vi.advanceTimersByTime(500) - }) - - expect(mockOnPageChange).toHaveBeenCalledWith(1) - }) - }) - - describe('onChangeStatus', () => { - it('should set selectedStatus to "all" when value is "all"', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 'all', name: 'All' }) - }) - - expect(result.current.selectedStatus).toBe('all') - }) - - it('should set selectedStatus to true when value is truthy', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 1, name: 'Enabled' }) - }) - - expect(result.current.selectedStatus).toBe(true) - }) - - it('should set selectedStatus to false when value is falsy (0)', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 0, name: 'Disabled' }) - }) - - expect(result.current.selectedStatus).toBe(false) - }) - - it('should call onPageChange(1) when status changes', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 1, name: 'Enabled' }) - }) - - expect(mockOnPageChange).toHaveBeenCalledWith(1) - }) - }) - - describe('onClearFilter', () => { - it('should reset all filter values', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - // Set some values first - act(() => { - result.current.handleInputChange('test') - vi.advanceTimersByTime(500) - result.current.onChangeStatus({ value: 1, name: 'Enabled' }) - }) - - // Clear filters - act(() => { - result.current.onClearFilter() - }) - - expect(result.current.inputValue).toBe('') - expect(result.current.searchValue).toBe('') - expect(result.current.selectedStatus).toBe('all') - }) - - it('should call onPageChange(1) when clearing', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - mockOnPageChange.mockClear() - - act(() => { - result.current.onClearFilter() - }) - - expect(mockOnPageChange).toHaveBeenCalledWith(1) - }) - }) - - describe('selectDefaultValue', () => { - it('should return "all" when selectedStatus is "all"', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - expect(result.current.selectDefaultValue).toBe('all') - }) - - it('should return 1 when selectedStatus is true', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 1, name: 'Enabled' }) - }) - - expect(result.current.selectDefaultValue).toBe(1) - }) - - it('should return 0 when selectedStatus is false', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 0, name: 'Disabled' }) - }) - - expect(result.current.selectDefaultValue).toBe(0) - }) - }) - - describe('Callback Stability', () => { - it('should maintain stable callback references', () => { - const { result, rerender } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - const initialHandleInputChange = result.current.handleInputChange - const initialOnChangeStatus = result.current.onChangeStatus - const initialOnClearFilter = result.current.onClearFilter - const initialResetPage = result.current.resetPage - - rerender() - - expect(result.current.handleInputChange).toBe(initialHandleInputChange) - expect(result.current.onChangeStatus).toBe(initialOnChangeStatus) - expect(result.current.onClearFilter).toBe(initialOnClearFilter) - expect(result.current.resetPage).toBe(initialResetPage) - }) - }) -}) - -// ============================================================================ -// useSegmentSelection Hook Tests -// ============================================================================ - -describe('useSegmentSelection', () => { - const mockSegments: SegmentDetailModel[] = [ - createMockSegmentDetail({ id: 'seg-1' }), - createMockSegmentDetail({ id: 'seg-2' }), - createMockSegmentDetail({ id: 'seg-3' }), - ] - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Initial State', () => { - it('should initialize with empty selection', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - expect(result.current.selectedSegmentIds).toEqual([]) - expect(result.current.isAllSelected).toBe(false) - expect(result.current.isSomeSelected).toBe(false) - }) - }) - - describe('onSelected', () => { - it('should add segment to selection when not selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - expect(result.current.selectedSegmentIds).toContain('seg-1') - }) - - it('should remove segment from selection when already selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - expect(result.current.selectedSegmentIds).toContain('seg-1') - - act(() => { - result.current.onSelected('seg-1') - }) - - expect(result.current.selectedSegmentIds).not.toContain('seg-1') - }) - - it('should allow multiple selections', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - result.current.onSelected('seg-2') - }) - - expect(result.current.selectedSegmentIds).toContain('seg-1') - expect(result.current.selectedSegmentIds).toContain('seg-2') - }) - }) - - describe('isAllSelected', () => { - it('should return false when no segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - expect(result.current.isAllSelected).toBe(false) - }) - - it('should return false when some segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - expect(result.current.isAllSelected).toBe(false) - }) - - it('should return true when all segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - mockSegments.forEach(seg => result.current.onSelected(seg.id)) - }) - - expect(result.current.isAllSelected).toBe(true) - }) - - it('should return false when segments array is empty', () => { - const { result } = renderHook(() => useSegmentSelection([])) - - expect(result.current.isAllSelected).toBe(false) - }) - }) - - describe('isSomeSelected', () => { - it('should return false when no segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - expect(result.current.isSomeSelected).toBe(false) - }) - - it('should return true when some segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - expect(result.current.isSomeSelected).toBe(true) - }) - - it('should return true when all segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - mockSegments.forEach(seg => result.current.onSelected(seg.id)) - }) - - expect(result.current.isSomeSelected).toBe(true) - }) - }) - - describe('onSelectedAll', () => { - it('should select all segments when none selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelectedAll() - }) - - expect(result.current.isAllSelected).toBe(true) - expect(result.current.selectedSegmentIds).toHaveLength(3) - }) - - it('should deselect all segments when all selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - // Select all first - act(() => { - result.current.onSelectedAll() - }) - - expect(result.current.isAllSelected).toBe(true) - - // Deselect all - act(() => { - result.current.onSelectedAll() - }) - - expect(result.current.isAllSelected).toBe(false) - expect(result.current.selectedSegmentIds).toHaveLength(0) - }) - - it('should select remaining segments when some selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - act(() => { - result.current.onSelectedAll() - }) - - expect(result.current.isAllSelected).toBe(true) - }) - - it('should preserve selection of segments not in current list', () => { - const { result, rerender } = renderHook( - ({ segments }) => useSegmentSelection(segments), - { initialProps: { segments: mockSegments } }, - ) - - // Select segment from initial list - act(() => { - result.current.onSelected('seg-1') - }) - - // Update segments list (simulating pagination) - const newSegments = [ - createMockSegmentDetail({ id: 'seg-4' }), - createMockSegmentDetail({ id: 'seg-5' }), - ] - - rerender({ segments: newSegments }) - - // Select all in new list - act(() => { - result.current.onSelectedAll() - }) - - // Should have seg-1 from old list plus seg-4 and seg-5 from new list - expect(result.current.selectedSegmentIds).toContain('seg-1') - expect(result.current.selectedSegmentIds).toContain('seg-4') - expect(result.current.selectedSegmentIds).toContain('seg-5') - }) - }) - - describe('onCancelBatchOperation', () => { - it('should clear all selections', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - result.current.onSelected('seg-2') - }) - - expect(result.current.selectedSegmentIds).toHaveLength(2) - - act(() => { - result.current.onCancelBatchOperation() - }) - - expect(result.current.selectedSegmentIds).toHaveLength(0) - }) - }) - - describe('clearSelection', () => { - it('should clear all selections', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - act(() => { - result.current.clearSelection() - }) - - expect(result.current.selectedSegmentIds).toHaveLength(0) - }) - }) - - describe('Callback Stability', () => { - it('should maintain stable callback references for state-independent callbacks', () => { - const { result, rerender } = renderHook(() => useSegmentSelection(mockSegments)) - - const initialOnSelected = result.current.onSelected - const initialOnCancelBatchOperation = result.current.onCancelBatchOperation - const initialClearSelection = result.current.clearSelection - - // Trigger a state change - act(() => { - result.current.onSelected('seg-1') - }) - - rerender() - - // These callbacks don't depend on state, so they should be stable - expect(result.current.onSelected).toBe(initialOnSelected) - expect(result.current.onCancelBatchOperation).toBe(initialOnCancelBatchOperation) - expect(result.current.clearSelection).toBe(initialClearSelection) - }) - - it('should update onSelectedAll when isAllSelected changes', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - const initialOnSelectedAll = result.current.onSelectedAll - - // Select all segments to change isAllSelected - act(() => { - mockSegments.forEach(seg => result.current.onSelected(seg.id)) - }) - - // onSelectedAll depends on isAllSelected, so it should change - expect(result.current.onSelectedAll).not.toBe(initialOnSelectedAll) - }) - }) -}) - -// ============================================================================ -// useModalState Hook Tests -// ============================================================================ - -describe('useModalState', () => { - const mockOnNewSegmentModalChange = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Initial State', () => { - it('should initialize with all modals closed', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - expect(result.current.currSegment.showModal).toBe(false) - expect(result.current.currChildChunk.showModal).toBe(false) - expect(result.current.showNewChildSegmentModal).toBe(false) - expect(result.current.isRegenerationModalOpen).toBe(false) - expect(result.current.fullScreen).toBe(false) - expect(result.current.isCollapsed).toBe(true) - }) - - it('should initialize currChunkId as empty string', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - expect(result.current.currChunkId).toBe('') - }) - }) - - describe('Segment Detail Modal', () => { - it('should open segment detail modal with correct data', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const mockSegment = createMockSegmentDetail({ id: 'test-seg' }) - - act(() => { - result.current.onClickCard(mockSegment) - }) - - expect(result.current.currSegment.showModal).toBe(true) - expect(result.current.currSegment.segInfo).toEqual(mockSegment) - expect(result.current.currSegment.isEditMode).toBe(false) - }) - - it('should open segment detail modal in edit mode', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const mockSegment = createMockSegmentDetail({ id: 'test-seg' }) - - act(() => { - result.current.onClickCard(mockSegment, true) - }) - - expect(result.current.currSegment.isEditMode).toBe(true) - }) - - it('should close segment detail modal and reset fullScreen', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const mockSegment = createMockSegmentDetail({ id: 'test-seg' }) - - act(() => { - result.current.onClickCard(mockSegment) - result.current.setFullScreen(true) - }) - - expect(result.current.currSegment.showModal).toBe(true) - expect(result.current.fullScreen).toBe(true) - - act(() => { - result.current.onCloseSegmentDetail() - }) - - expect(result.current.currSegment.showModal).toBe(false) - expect(result.current.fullScreen).toBe(false) - }) - }) - - describe('Child Segment Detail Modal', () => { - it('should open child segment detail modal with correct data', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const mockChildChunk = createMockChildChunk({ id: 'child-1', segment_id: 'parent-1' }) - - act(() => { - result.current.onClickSlice(mockChildChunk) - }) - - expect(result.current.currChildChunk.showModal).toBe(true) - expect(result.current.currChildChunk.childChunkInfo).toEqual(mockChildChunk) - expect(result.current.currChunkId).toBe('parent-1') - }) - - it('should close child segment detail modal and reset fullScreen', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const mockChildChunk = createMockChildChunk() - - act(() => { - result.current.onClickSlice(mockChildChunk) - result.current.setFullScreen(true) - }) - - act(() => { - result.current.onCloseChildSegmentDetail() - }) - - expect(result.current.currChildChunk.showModal).toBe(false) - expect(result.current.fullScreen).toBe(false) - }) - }) - - describe('New Segment Modal', () => { - it('should call onNewSegmentModalChange and reset fullScreen when closing', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - act(() => { - result.current.setFullScreen(true) - }) - - act(() => { - result.current.onCloseNewSegmentModal() - }) - - expect(mockOnNewSegmentModalChange).toHaveBeenCalledWith(false) - expect(result.current.fullScreen).toBe(false) - }) - }) - - describe('New Child Segment Modal', () => { - it('should open new child segment modal and set currChunkId', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - act(() => { - result.current.handleAddNewChildChunk('parent-chunk-id') - }) - - expect(result.current.showNewChildSegmentModal).toBe(true) - expect(result.current.currChunkId).toBe('parent-chunk-id') - }) - - it('should close new child segment modal and reset fullScreen', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - act(() => { - result.current.handleAddNewChildChunk('parent-chunk-id') - result.current.setFullScreen(true) - }) - - act(() => { - result.current.onCloseNewChildChunkModal() - }) - - expect(result.current.showNewChildSegmentModal).toBe(false) - expect(result.current.fullScreen).toBe(false) - }) - }) - - describe('Display State', () => { - it('should toggle fullScreen', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - expect(result.current.fullScreen).toBe(false) - - act(() => { - result.current.toggleFullScreen() - }) - - expect(result.current.fullScreen).toBe(true) - - act(() => { - result.current.toggleFullScreen() - }) - - expect(result.current.fullScreen).toBe(false) - }) - - it('should set fullScreen directly', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - act(() => { - result.current.setFullScreen(true) - }) - - expect(result.current.fullScreen).toBe(true) - }) - - it('should toggle isCollapsed', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - expect(result.current.isCollapsed).toBe(true) - - act(() => { - result.current.toggleCollapsed() - }) - - expect(result.current.isCollapsed).toBe(false) - - act(() => { - result.current.toggleCollapsed() - }) - - expect(result.current.isCollapsed).toBe(true) - }) - }) - - describe('Regeneration Modal', () => { - it('should set isRegenerationModalOpen', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - act(() => { - result.current.setIsRegenerationModalOpen(true) - }) - - expect(result.current.isRegenerationModalOpen).toBe(true) - - act(() => { - result.current.setIsRegenerationModalOpen(false) - }) - - expect(result.current.isRegenerationModalOpen).toBe(false) - }) - }) - - describe('Callback Stability', () => { - it('should maintain stable callback references', () => { - const { result, rerender } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const initialCallbacks = { - onClickCard: result.current.onClickCard, - onCloseSegmentDetail: result.current.onCloseSegmentDetail, - onClickSlice: result.current.onClickSlice, - onCloseChildSegmentDetail: result.current.onCloseChildSegmentDetail, - handleAddNewChildChunk: result.current.handleAddNewChildChunk, - onCloseNewChildChunkModal: result.current.onCloseNewChildChunkModal, - toggleFullScreen: result.current.toggleFullScreen, - toggleCollapsed: result.current.toggleCollapsed, - } - - rerender() - - expect(result.current.onClickCard).toBe(initialCallbacks.onClickCard) - expect(result.current.onCloseSegmentDetail).toBe(initialCallbacks.onCloseSegmentDetail) - expect(result.current.onClickSlice).toBe(initialCallbacks.onClickSlice) - expect(result.current.onCloseChildSegmentDetail).toBe(initialCallbacks.onCloseChildSegmentDetail) - expect(result.current.handleAddNewChildChunk).toBe(initialCallbacks.handleAddNewChildChunk) - expect(result.current.onCloseNewChildChunkModal).toBe(initialCallbacks.onCloseNewChildChunkModal) - expect(result.current.toggleFullScreen).toBe(initialCallbacks.toggleFullScreen) - expect(result.current.toggleCollapsed).toBe(initialCallbacks.toggleCollapsed) - }) - }) -}) - -// ============================================================================ -// SegmentListContext Tests -// ============================================================================ - describe('SegmentListContext', () => { describe('Default Values', () => { it('should have correct default context values', () => { @@ -1195,9 +372,7 @@ describe('SegmentListContext', () => { }) }) -// ============================================================================ // Completed Component Tests -// ============================================================================ describe('Completed Component', () => { const defaultProps = { @@ -1340,59 +515,6 @@ describe('Completed Component', () => { }) }) -// ============================================================================ -// MenuBar Component Tests (via mock verification) -// ============================================================================ - -describe('MenuBar Component', () => { - const defaultProps = { - embeddingAvailable: true, - showNewSegmentModal: false, - onNewSegmentModalChange: vi.fn(), - importStatus: undefined, - archived: false, - } - - beforeEach(() => { - vi.clearAllMocks() - mockDocForm.current = ChunkingModeEnum.text - mockParentMode.current = 'paragraph' - }) - - it('should pass correct props to MenuBar', () => { - render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) - - const menuBar = screen.getByTestId('menu-bar') - expect(menuBar).toBeInTheDocument() - - // Total text should be displayed - const totalText = screen.getByTestId('total-text') - expect(totalText).toHaveTextContent('chunks') - }) - - it('should handle search input changes', async () => { - render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) - - const searchInput = screen.getByTestId('search-input') - fireEvent.change(searchInput, { target: { value: 'test search' } }) - - expect(searchInput).toHaveValue('test search') - }) - - it('should disable search input when loading', () => { - // Loading state is controlled by the segment list hook - render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) - - const searchInput = screen.getByTestId('search-input') - // When not loading, input should not be disabled - expect(searchInput).not.toBeDisabled() - }) -}) - -// ============================================================================ -// Edge Cases and Error Handling -// ============================================================================ - describe('Edge Cases', () => { const defaultProps = { embeddingAvailable: true, @@ -1469,10 +591,6 @@ describe('Edge Cases', () => { }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('Integration Tests', () => { const defaultProps = { embeddingAvailable: true, @@ -1522,26 +640,7 @@ describe('Integration Tests', () => { }) }) -// ============================================================================ -// useSearchFilter - resetPage Tests -// ============================================================================ - -describe('useSearchFilter - resetPage', () => { - it('should call onPageChange with 1 when resetPage is called', () => { - const mockOnPageChange = vi.fn() - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.resetPage() - }) - - expect(mockOnPageChange).toHaveBeenCalledWith(1) - }) -}) - -// ============================================================================ // Batch Action Tests -// ============================================================================ describe('Batch Action Callbacks', () => { const defaultProps = { @@ -1597,7 +696,6 @@ describe('Batch Action Callbacks', () => { it('should render batch actions after selecting all segments', async () => { render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) - // Click the select all button to select all segments const selectAllButton = screen.getByTestId('select-all-button') fireEvent.click(selectAllButton) @@ -1619,7 +717,6 @@ describe('Batch Action Callbacks', () => { expect(screen.getByTestId('batch-action')).toBeInTheDocument() }) - // Click the enable button const enableButton = screen.getByTestId('batch-enable') fireEvent.click(enableButton) @@ -1638,7 +735,6 @@ describe('Batch Action Callbacks', () => { expect(screen.getByTestId('batch-action')).toBeInTheDocument() }) - // Click the disable button const disableButton = screen.getByTestId('batch-disable') fireEvent.click(disableButton) @@ -1657,7 +753,6 @@ describe('Batch Action Callbacks', () => { expect(screen.getByTestId('batch-action')).toBeInTheDocument() }) - // Click the delete button const deleteButton = screen.getByTestId('batch-delete') fireEvent.click(deleteButton) @@ -1665,9 +760,7 @@ describe('Batch Action Callbacks', () => { }) }) -// ============================================================================ // refreshChunkListDataWithDetailChanged Tests -// ============================================================================ describe('refreshChunkListDataWithDetailChanged callback', () => { const defaultProps = { @@ -1774,9 +867,7 @@ describe('refreshChunkListDataWithDetailChanged callback', () => { }) }) -// ============================================================================ // refreshChunkListDataWithDetailChanged Branch Coverage Tests -// ============================================================================ describe('refreshChunkListDataWithDetailChanged branch coverage', () => { // This test simulates the behavior of refreshChunkListDataWithDetailChanged @@ -1823,9 +914,7 @@ describe('refreshChunkListDataWithDetailChanged branch coverage', () => { }) }) -// ============================================================================ // Batch Action Callback Coverage Tests -// ============================================================================ describe('Batch Action callback simulation', () => { // This test simulates the batch action callback behavior @@ -1861,3 +950,191 @@ describe('Batch Action callback simulation', () => { expect(mockOnDelete).toHaveBeenCalledWith('') }) }) + +// Additional Coverage Tests for Inline Callbacks (lines 56-66, 78-83, 254) + +describe('Inline callback and hook initialization coverage', () => { + const defaultProps = { + embeddingAvailable: true, + showNewSegmentModal: false, + onNewSegmentModalChange: vi.fn(), + importStatus: undefined, + archived: false, + } + + beforeEach(() => { + vi.clearAllMocks() + capturedRefreshCallback = null + mockDocForm.current = ChunkingModeEnum.text + mockParentMode.current = 'paragraph' + mockDatasetId.current = 'test-dataset-id' + mockDocumentId.current = 'test-document-id' + mockSegmentListData.data = [ + createMockSegmentDetail({ id: 'seg-cov-1' }), + createMockSegmentDetail({ id: 'seg-cov-2' }), + ] + mockSegmentListData.total = 2 + }) + + // Covers lines 56-58: useSearchFilter({ onPageChange: setCurrentPage }) + it('should reset current page when status filter changes', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('next-page')) + await waitFor(() => { + expect(screen.getByTestId('current-page')).toHaveTextContent('1') + }) + + fireEvent.click(screen.getByTestId('status-enabled')) + + await waitFor(() => { + expect(screen.getByTestId('current-page')).toHaveTextContent('0') + }) + }) + + // Covers lines 61-63: useModalState({ onNewSegmentModalChange }) + it('should pass onNewSegmentModalChange to modal state hook', () => { + const mockOnChange = vi.fn() + render( + <Completed {...defaultProps} onNewSegmentModalChange={mockOnChange} />, + { wrapper: createWrapper() }, + ) + expect(screen.getByTestId('drawer-group')).toBeInTheDocument() + }) + + // Covers lines 74-90: refreshChunkListDataWithDetailChanged with status true + it('should invoke correct invalidation for enabled status', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('status-enabled')) + + await waitFor(() => { + expect(capturedRefreshCallback).toBeDefined() + }) + + mockInvalidChunkListAll.mockClear() + mockInvalidChunkListDisabled.mockClear() + mockInvalidChunkListEnabled.mockClear() + + capturedRefreshCallback!() + + expect(mockInvalidChunkListAll).toHaveBeenCalled() + expect(mockInvalidChunkListDisabled).toHaveBeenCalled() + }) + + // Covers lines 74-90: refreshChunkListDataWithDetailChanged with status false + it('should invoke correct invalidation for disabled status', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('status-disabled')) + + await waitFor(() => { + expect(capturedRefreshCallback).toBeDefined() + }) + + mockInvalidChunkListAll.mockClear() + mockInvalidChunkListDisabled.mockClear() + mockInvalidChunkListEnabled.mockClear() + + capturedRefreshCallback!() + + expect(mockInvalidChunkListAll).toHaveBeenCalled() + expect(mockInvalidChunkListEnabled).toHaveBeenCalled() + }) + + // Covers line 101: clearSelection callback + it('should clear selection via batch cancel', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('select-all-button')) + + await waitFor(() => { + expect(screen.getByTestId('batch-action')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('cancel-batch')) + + await waitFor(() => { + expect(screen.queryByTestId('batch-action')).not.toBeInTheDocument() + }) + }) + + // Covers line 252-254: batch action callbacks + it('should call batch enable through real callback chain', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('select-all-button')) + await waitFor(() => { + expect(screen.getByTestId('batch-action')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('batch-enable')) + await waitFor(() => { + expect(mockOnChangeSwitch).toHaveBeenCalled() + }) + }) + + it('should call batch disable through real callback chain', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('select-all-button')) + await waitFor(() => { + expect(screen.getByTestId('batch-action')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('batch-disable')) + await waitFor(() => { + expect(mockOnChangeSwitch).toHaveBeenCalled() + }) + }) + + it('should call batch delete through real callback chain', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('select-all-button')) + await waitFor(() => { + expect(screen.getByTestId('batch-action')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('batch-delete')) + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalled() + }) + }) + + // Covers line 133-135: handlePageChange + it('should handle multiple page changes', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('next-page')) + await waitFor(() => { + expect(screen.getByTestId('current-page')).toHaveTextContent('1') + }) + + fireEvent.click(screen.getByTestId('next-page')) + await waitFor(() => { + expect(screen.getByTestId('current-page')).toHaveTextContent('2') + }) + }) + + // Covers paginationTotal in full-doc mode + it('should compute pagination total from child chunk data in full-doc mode', () => { + mockDocForm.current = ChunkingModeEnum.parentChild + mockParentMode.current = 'full-doc' + mockChildSegmentListData.total = 42 + + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + expect(screen.getByTestId('total-items')).toHaveTextContent('42') + }) + + // Covers search input change + it('should handle search input change', () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + const searchInput = screen.getByTestId('search-input') + fireEvent.change(searchInput, { target: { value: 'test query' } }) + + expect(searchInput).toHaveValue('test query') + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx index 8e936a2c4a..1b26a15b65 100644 --- a/web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx @@ -1,9 +1,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import NewChildSegmentModal from './new-child-segment' +import NewChildSegmentModal from '../new-child-segment' -// Mock next/navigation vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id', @@ -11,7 +10,6 @@ vi.mock('next/navigation', () => ({ }), })) -// Mock ToastContext const mockNotify = vi.fn() vi.mock('use-context-selector', async (importOriginal) => { const actual = await importOriginal() as Record<string, unknown> @@ -23,7 +21,7 @@ vi.mock('use-context-selector', async (importOriginal) => { // Mock document context let mockParentMode = 'paragraph' -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => { return selector({ parentMode: mockParentMode }) }, @@ -32,7 +30,7 @@ vi.mock('../context', () => ({ // Mock segment list context let mockFullScreen = false const mockToggleFullScreen = vi.fn() -vi.mock('./index', () => ({ +vi.mock('../index', () => ({ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { const state = { fullScreen: mockFullScreen, @@ -55,8 +53,7 @@ vi.mock('@/app/components/app/store', () => ({ useStore: () => ({ appSidebarExpand: 'expand' }), })) -// Mock child components -vi.mock('./common/action-buttons', () => ({ +vi.mock('../common/action-buttons', () => ({ default: ({ handleCancel, handleSave, loading, actionType, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string, isChildChunk?: boolean }) => ( <div data-testid="action-buttons"> <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> @@ -69,7 +66,7 @@ vi.mock('./common/action-buttons', () => ({ ), })) -vi.mock('./common/add-another', () => ({ +vi.mock('../common/add-another', () => ({ default: ({ isChecked, onCheck, className }: { isChecked: boolean, onCheck: () => void, className?: string }) => ( <div data-testid="add-another" className={className}> <input @@ -82,7 +79,7 @@ vi.mock('./common/add-another', () => ({ ), })) -vi.mock('./common/chunk-content', () => ({ +vi.mock('../common/chunk-content', () => ({ default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => ( <div data-testid="chunk-content"> <input @@ -95,11 +92,11 @@ vi.mock('./common/chunk-content', () => ({ ), })) -vi.mock('./common/dot', () => ({ +vi.mock('../common/dot', () => ({ default: () => <span data-testid="dot">‱</span>, })) -vi.mock('./common/segment-index-tag', () => ({ +vi.mock('../common/segment-index-tag', () => ({ SegmentIndexTag: ({ label }: { label: string }) => <span data-testid="segment-index-tag">{label}</span>, })) @@ -117,102 +114,78 @@ describe('NewChildSegmentModal', () => { viewNewlyAddedChildChunk: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render add child chunk title', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.addChildChunk/i)).toBeInTheDocument() }) it('should render chunk content component', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('chunk-content')).toBeInTheDocument() }) it('should render segment index tag with new child chunk label', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument() }) it('should render add another checkbox', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('add-another')).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() const { container } = render( <NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />, ) - // Act const closeButtons = container.querySelectorAll('.cursor-pointer') if (closeButtons.length > 1) fireEvent.click(closeButtons[1]) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - // Arrange const { container } = render(<NewChildSegmentModal {...defaultProps} />) - // Act const expandButtons = container.querySelectorAll('.cursor-pointer') if (expandButtons.length > 0) fireEvent.click(expandButtons[0]) - // Assert expect(mockToggleFullScreen).toHaveBeenCalled() }) it('should update content when input changes', () => { - // Arrange render(<NewChildSegmentModal {...defaultProps} />) - // Act fireEvent.change(screen.getByTestId('content-input'), { target: { value: 'New content' }, }) - // Assert expect(screen.getByTestId('content-input')).toHaveValue('New content') }) it('should toggle add another checkbox', () => { - // Arrange render(<NewChildSegmentModal {...defaultProps} />) const checkbox = screen.getByTestId('add-another-checkbox') - // Act fireEvent.click(checkbox) - // Assert expect(checkbox).toBeInTheDocument() }) }) @@ -220,13 +193,10 @@ describe('NewChildSegmentModal', () => { // Save validation describe('Save Validation', () => { it('should show error when content is empty', async () => { - // Arrange render(<NewChildSegmentModal {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -240,7 +210,6 @@ describe('NewChildSegmentModal', () => { // Successful save describe('Successful Save', () => { it('should call addChildSegment when valid content is provided', async () => { - // Arrange mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) options.onSettled() @@ -252,10 +221,8 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockAddChildSegment).toHaveBeenCalledWith( expect.objectContaining({ @@ -272,7 +239,6 @@ describe('NewChildSegmentModal', () => { }) it('should show success notification after save', async () => { - // Arrange mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) options.onSettled() @@ -284,10 +250,8 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -301,24 +265,18 @@ describe('NewChildSegmentModal', () => { // Full screen mode describe('Full Screen Mode', () => { it('should show action buttons in header when fullScreen', () => { - // Arrange mockFullScreen = true - // Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) it('should show add another in header when fullScreen', () => { - // Arrange mockFullScreen = true - // Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('add-another')).toBeInTheDocument() }) }) @@ -326,51 +284,38 @@ describe('NewChildSegmentModal', () => { // Props describe('Props', () => { it('should pass actionType add to ActionButtons', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('action-type')).toHaveTextContent('add') }) it('should pass isChildChunk true to ActionButtons', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true') }) it('should pass isEditMode true to ChunkContent', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined viewNewlyAddedChildChunk', () => { - // Arrange const props = { ...defaultProps, viewNewlyAddedChildChunk: undefined } - // Act const { container } = render(<NewChildSegmentModal {...props} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<NewChildSegmentModal {...defaultProps} />) - // Act rerender(<NewChildSegmentModal {...defaultProps} chunkId="chunk-2" />) - // Assert expect(screen.getByTestId('chunk-content')).toBeInTheDocument() }) }) @@ -378,7 +323,6 @@ describe('NewChildSegmentModal', () => { // Add another behavior describe('Add Another Behavior', () => { it('should close modal when add another is unchecked after save', async () => { - // Arrange const mockOnCancel = vi.fn() mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) @@ -396,7 +340,6 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) // Assert - modal should close @@ -406,7 +349,6 @@ describe('NewChildSegmentModal', () => { }) it('should not close modal when add another is checked after save', async () => { - // Arrange const mockOnCancel = vi.fn() mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) @@ -421,7 +363,6 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) // Assert - modal should not close, only content cleared @@ -434,7 +375,6 @@ describe('NewChildSegmentModal', () => { // View newly added chunk describe('View Newly Added Chunk', () => { it('should show custom button in full-doc mode after save', async () => { - // Arrange mockParentMode = 'full-doc' mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) @@ -449,7 +389,6 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) // Assert - success notification with custom component @@ -464,7 +403,6 @@ describe('NewChildSegmentModal', () => { }) it('should not show custom button in paragraph mode after save', async () => { - // Arrange mockParentMode = 'paragraph' const mockOnSave = vi.fn() mockAddChildSegment.mockImplementation((_params, options) => { @@ -480,7 +418,6 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) // Assert - onSave should be called with data @@ -493,14 +430,11 @@ describe('NewChildSegmentModal', () => { // Cancel behavior describe('Cancel Behavior', () => { it('should call onCancel when close button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() render(<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />) - // Act fireEvent.click(screen.getByTestId('cancel-btn')) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx index 479958ea2d..dbce9b7f22 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode } from '@/models/datasets' -import SegmentDetail from './segment-detail' +import SegmentDetail from '../segment-detail' // Mock dataset detail context let mockIndexingTechnique = IndexingType.QUALIFIED @@ -21,7 +21,7 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock document context let mockParentMode = 'paragraph' -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => { return selector({ parentMode: mockParentMode }) }, @@ -30,7 +30,7 @@ vi.mock('../context', () => ({ // Mock segment list context let mockFullScreen = false const mockToggleFullScreen = vi.fn() -vi.mock('./index', () => ({ +vi.mock('../index', () => ({ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { const state = { fullScreen: mockFullScreen, @@ -49,8 +49,7 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -// Mock child components -vi.mock('./common/action-buttons', () => ({ +vi.mock('../common/action-buttons', () => ({ default: ({ handleCancel, handleSave, handleRegeneration, loading, showRegenerationButton }: { handleCancel: () => void, handleSave: () => void, handleRegeneration?: () => void, loading: boolean, showRegenerationButton?: boolean }) => ( <div data-testid="action-buttons"> <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> @@ -62,7 +61,7 @@ vi.mock('./common/action-buttons', () => ({ ), })) -vi.mock('./common/chunk-content', () => ({ +vi.mock('../common/chunk-content', () => ({ default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => ( <div data-testid="chunk-content"> <input @@ -82,11 +81,11 @@ vi.mock('./common/chunk-content', () => ({ ), })) -vi.mock('./common/dot', () => ({ +vi.mock('../common/dot', () => ({ default: () => <span data-testid="dot">‱</span>, })) -vi.mock('./common/keywords', () => ({ +vi.mock('../common/keywords', () => ({ default: ({ keywords, onKeywordsChange, _isEditMode, actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, actionType: string }) => ( <div data-testid="keywords"> <span data-testid="keywords-action">{actionType}</span> @@ -99,7 +98,7 @@ vi.mock('./common/keywords', () => ({ ), })) -vi.mock('./common/segment-index-tag', () => ({ +vi.mock('../common/segment-index-tag', () => ({ SegmentIndexTag: ({ positionId, label, labelPrefix }: { positionId?: string, label?: string, labelPrefix?: string }) => ( <span data-testid="segment-index-tag"> {labelPrefix} @@ -111,7 +110,7 @@ vi.mock('./common/segment-index-tag', () => ({ ), })) -vi.mock('./common/regeneration-modal', () => ({ +vi.mock('../common/regeneration-modal', () => ({ default: ({ isShow, onConfirm, onCancel, onClose }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, onClose: () => void }) => ( isShow ? ( @@ -171,53 +170,40 @@ describe('SegmentDetail', () => { onModalStateChange: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<SegmentDetail {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render title for view mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={false} />) - // Assert expect(screen.getByText(/segment\.chunkDetail/i)).toBeInTheDocument() }) it('should render title for edit mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByText(/segment\.editChunk/i)).toBeInTheDocument() }) it('should render chunk content component', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('chunk-content')).toBeInTheDocument() }) it('should render image uploader', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('image-uploader')).toBeInTheDocument() }) it('should render segment index tag', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument() }) }) @@ -225,42 +211,32 @@ describe('SegmentDetail', () => { // Edit mode vs View mode describe('Edit/View Mode', () => { it('should pass isEditMode to ChunkContent', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing') }) it('should disable image uploader in view mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={false} />) - // Assert expect(screen.getByTestId('uploader-disabled')).toHaveTextContent('disabled') }) it('should enable image uploader in edit mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('uploader-disabled')).toHaveTextContent('enabled') }) it('should show action buttons in edit mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) it('should not show action buttons in view mode (non-fullscreen)', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={false} />) - // Assert expect(screen.queryByTestId('action-buttons')).not.toBeInTheDocument() }) }) @@ -268,88 +244,66 @@ describe('SegmentDetail', () => { // Keywords display describe('Keywords', () => { it('should show keywords component when indexing is ECONOMICAL', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL - // Act render(<SegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('keywords')).toBeInTheDocument() }) it('should not show keywords when indexing is QUALIFIED', () => { - // Arrange mockIndexingTechnique = IndexingType.QUALIFIED - // Act render(<SegmentDetail {...defaultProps} />) - // Assert expect(screen.queryByTestId('keywords')).not.toBeInTheDocument() }) it('should pass view action type when not in edit mode', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL - // Act render(<SegmentDetail {...defaultProps} isEditMode={false} />) - // Assert expect(screen.getByTestId('keywords-action')).toHaveTextContent('view') }) it('should pass edit action type when in edit mode', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL - // Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('keywords-action')).toHaveTextContent('edit') }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() const { container } = render(<SegmentDetail {...defaultProps} onCancel={mockOnCancel} />) - // Act const closeButtons = container.querySelectorAll('.cursor-pointer') if (closeButtons.length > 1) fireEvent.click(closeButtons[1]) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - // Arrange const { container } = render(<SegmentDetail {...defaultProps} />) - // Act const expandButtons = container.querySelectorAll('.cursor-pointer') if (expandButtons.length > 0) fireEvent.click(expandButtons[0]) - // Assert expect(mockToggleFullScreen).toHaveBeenCalled() }) it('should call onUpdate when save is clicked', () => { - // Arrange const mockOnUpdate = vi.fn() render(<SegmentDetail {...defaultProps} isEditMode={true} onUpdate={mockOnUpdate} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith( 'segment-1', expect.any(String), @@ -362,15 +316,12 @@ describe('SegmentDetail', () => { }) it('should update question when input changes', () => { - // Arrange render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Act fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Updated content' }, }) - // Assert expect(screen.getByTestId('question-input')).toHaveValue('Updated content') }) }) @@ -378,40 +329,30 @@ describe('SegmentDetail', () => { // Regeneration Modal describe('Regeneration Modal', () => { it('should show regeneration button when runtimeMode is general', () => { - // Arrange mockRuntimeMode = 'general' - // Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument() }) it('should not show regeneration button when runtimeMode is not general', () => { - // Arrange mockRuntimeMode = 'pipeline' - // Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.queryByTestId('regenerate-btn')).not.toBeInTheDocument() }) it('should show regeneration modal when regenerate is clicked', () => { - // Arrange render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Act fireEvent.click(screen.getByTestId('regenerate-btn')) - // Assert expect(screen.getByTestId('regeneration-modal')).toBeInTheDocument() }) it('should call onModalStateChange when regeneration modal opens', () => { - // Arrange const mockOnModalStateChange = vi.fn() render( <SegmentDetail @@ -421,15 +362,12 @@ describe('SegmentDetail', () => { />, ) - // Act fireEvent.click(screen.getByTestId('regenerate-btn')) - // Assert expect(mockOnModalStateChange).toHaveBeenCalledWith(true) }) it('should close modal when cancel is clicked', () => { - // Arrange const mockOnModalStateChange = vi.fn() render( <SegmentDetail @@ -440,10 +378,8 @@ describe('SegmentDetail', () => { ) fireEvent.click(screen.getByTestId('regenerate-btn')) - // Act fireEvent.click(screen.getByTestId('cancel-regeneration')) - // Assert expect(mockOnModalStateChange).toHaveBeenCalledWith(false) expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument() }) @@ -452,66 +388,50 @@ describe('SegmentDetail', () => { // Full screen mode describe('Full Screen Mode', () => { it('should show action buttons in header when fullScreen and editMode', () => { - // Arrange mockFullScreen = true - // Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) it('should apply full screen styling when fullScreen is true', () => { - // Arrange mockFullScreen = true - // Act const { container } = render(<SegmentDetail {...defaultProps} />) - // Assert const header = container.querySelector('.border-divider-subtle') expect(header).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle segInfo with minimal data', () => { - // Arrange const minimalSegInfo = { id: 'segment-minimal', position: 1, word_count: 0, } - // Act const { container } = render(<SegmentDetail {...defaultProps} segInfo={minimalSegInfo} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle empty keywords array', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL const segInfo = { ...defaultSegInfo, keywords: [] } - // Act render(<SegmentDetail {...defaultProps} segInfo={segInfo} />) - // Assert expect(screen.getByTestId('keywords-input')).toHaveValue('') }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<SegmentDetail {...defaultProps} isEditMode={false} />) - // Act rerender(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) }) @@ -519,28 +439,22 @@ describe('SegmentDetail', () => { // Attachments describe('Attachments', () => { it('should update attachments when onChange is called', () => { - // Arrange render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Act fireEvent.click(screen.getByTestId('add-attachment-btn')) - // Assert expect(screen.getByTestId('attachments-count')).toHaveTextContent('1') }) it('should pass attachments to onUpdate when save is clicked', () => { - // Arrange const mockOnUpdate = vi.fn() render(<SegmentDetail {...defaultProps} isEditMode={true} onUpdate={mockOnUpdate} />) // Add an attachment fireEvent.click(screen.getByTestId('add-attachment-btn')) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith( 'segment-1', expect.any(String), @@ -553,7 +467,6 @@ describe('SegmentDetail', () => { }) it('should initialize attachments from segInfo', () => { - // Arrange const segInfoWithAttachments = { ...defaultSegInfo, attachments: [ @@ -561,10 +474,8 @@ describe('SegmentDetail', () => { ], } - // Act render(<SegmentDetail {...defaultProps} segInfo={segInfoWithAttachments} isEditMode={true} />) - // Assert expect(screen.getByTestId('attachments-count')).toHaveTextContent('1') }) }) @@ -572,17 +483,14 @@ describe('SegmentDetail', () => { // Regeneration confirmation describe('Regeneration Confirmation', () => { it('should call onUpdate with needRegenerate true when confirm regeneration is clicked', () => { - // Arrange const mockOnUpdate = vi.fn() render(<SegmentDetail {...defaultProps} isEditMode={true} onUpdate={mockOnUpdate} />) // Open regeneration modal fireEvent.click(screen.getByTestId('regenerate-btn')) - // Act fireEvent.click(screen.getByTestId('confirm-regeneration')) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith( 'segment-1', expect.any(String), @@ -595,7 +503,6 @@ describe('SegmentDetail', () => { }) it('should close modal and edit drawer when close after regeneration is clicked', () => { - // Arrange const mockOnCancel = vi.fn() const mockOnModalStateChange = vi.fn() render( @@ -610,10 +517,8 @@ describe('SegmentDetail', () => { // Open regeneration modal fireEvent.click(screen.getByTestId('regenerate-btn')) - // Act fireEvent.click(screen.getByTestId('close-regeneration')) - // Assert expect(mockOnModalStateChange).toHaveBeenCalledWith(false) expect(mockOnCancel).toHaveBeenCalled() }) @@ -622,28 +527,22 @@ describe('SegmentDetail', () => { // QA mode describe('QA Mode', () => { it('should render answer input in QA mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.qa} isEditMode={true} />) - // Assert expect(screen.getByTestId('answer-input')).toBeInTheDocument() }) it('should update answer when input changes', () => { - // Arrange render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.qa} isEditMode={true} />) - // Act fireEvent.change(screen.getByTestId('answer-input'), { target: { value: 'Updated answer' }, }) - // Assert expect(screen.getByTestId('answer-input')).toHaveValue('Updated answer') }) it('should calculate word count correctly in QA mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.qa} isEditMode={true} />) // Assert - should show combined length of question and answer @@ -654,13 +553,10 @@ describe('SegmentDetail', () => { // Full doc mode describe('Full Doc Mode', () => { it('should show label in full-doc parent-child mode', () => { - // Arrange mockParentMode = 'full-doc' - // Act render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.parentChild} />) - // Assert expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument() }) }) @@ -668,16 +564,13 @@ describe('SegmentDetail', () => { // Keywords update describe('Keywords Update', () => { it('should update keywords when changed in edit mode', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Act fireEvent.change(screen.getByTestId('keywords-input'), { target: { value: 'new,keywords' }, }) - // Assert expect(screen.getByTestId('keywords-input')).toHaveValue('new,keywords') }) }) diff --git a/web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/segment-list.spec.tsx index 1716059883..caab14a8e9 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list.spec.tsx @@ -3,12 +3,12 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import SegmentList from './segment-list' +import SegmentList from '../segment-list' // Mock document context let mockDocForm = ChunkingMode.text let mockParentMode = 'paragraph' -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (state: { docForm: ChunkingMode, parentMode: string }) => unknown) => { return selector({ docForm: mockDocForm, @@ -20,7 +20,7 @@ vi.mock('../context', () => ({ // Mock segment list context let mockCurrSegment: { segInfo: { id: string } } | null = null let mockCurrChildChunk: { childChunkInfo: { segment_id: string } } | null = null -vi.mock('./index', () => ({ +vi.mock('../index', () => ({ useSegmentListContext: (selector: (state: { currSegment: { segInfo: { id: string } } | null, currChildChunk: { childChunkInfo: { segment_id: string } } | null }) => unknown) => { return selector({ currSegment: mockCurrSegment, @@ -29,8 +29,7 @@ vi.mock('./index', () => ({ }, })) -// Mock child components -vi.mock('./common/empty', () => ({ +vi.mock('../common/empty', () => ({ default: ({ onClearFilter }: { onClearFilter: () => void }) => ( <div data-testid="empty"> <button onClick={onClearFilter} data-testid="clear-filter-btn">Clear Filter</button> @@ -38,7 +37,7 @@ vi.mock('./common/empty', () => ({ ), })) -vi.mock('./segment-card', () => ({ +vi.mock('../segment-card', () => ({ default: ({ detail, onClick, @@ -81,11 +80,11 @@ vi.mock('./segment-card', () => ({ ), })) -vi.mock('./skeleton/general-list-skeleton', () => ({ +vi.mock('../skeleton/general-list-skeleton', () => ({ default: () => <div data-testid="general-skeleton">Loading...</div>, })) -vi.mock('./skeleton/paragraph-list-skeleton', () => ({ +vi.mock('../skeleton/paragraph-list-skeleton', () => ({ default: () => <div data-testid="paragraph-skeleton">Loading Paragraph...</div>, })) @@ -137,73 +136,55 @@ describe('SegmentList', () => { onClearFilter: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<SegmentList {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render segment cards for each item', () => { - // Arrange const items = [ createMockSegment('seg-1', 'Content 1'), createMockSegment('seg-2', 'Content 2'), ] - // Act render(<SegmentList {...defaultProps} items={items} />) - // Assert expect(screen.getAllByTestId('segment-card')).toHaveLength(2) }) it('should render empty component when items is empty', () => { - // Arrange & Act render(<SegmentList {...defaultProps} items={[]} />) - // Assert expect(screen.getByTestId('empty')).toBeInTheDocument() }) }) - // Loading state describe('Loading State', () => { it('should render general skeleton when loading and docForm is text', () => { - // Arrange mockDocForm = ChunkingMode.text - // Act render(<SegmentList {...defaultProps} isLoading={true} />) - // Assert expect(screen.getByTestId('general-skeleton')).toBeInTheDocument() }) it('should render paragraph skeleton when loading and docForm is parentChild with paragraph mode', () => { - // Arrange mockDocForm = ChunkingMode.parentChild mockParentMode = 'paragraph' - // Act render(<SegmentList {...defaultProps} isLoading={true} />) - // Assert expect(screen.getByTestId('paragraph-skeleton')).toBeInTheDocument() }) it('should render general skeleton when loading and docForm is parentChild with full-doc mode', () => { - // Arrange mockDocForm = ChunkingMode.parentChild mockParentMode = 'full-doc' - // Act render(<SegmentList {...defaultProps} isLoading={true} />) - // Assert expect(screen.getByTestId('general-skeleton')).toBeInTheDocument() }) }) @@ -211,18 +192,14 @@ describe('SegmentList', () => { // Props passing describe('Props Passing', () => { it('should pass archived prop to SegmentCard', () => { - // Arrange & Act render(<SegmentList {...defaultProps} archived={true} />) - // Assert expect(screen.getByTestId('archived')).toHaveTextContent('true') }) it('should pass embeddingAvailable prop to SegmentCard', () => { - // Arrange & Act render(<SegmentList {...defaultProps} embeddingAvailable={false} />) - // Assert expect(screen.getByTestId('embedding-available')).toHaveTextContent('false') }) }) @@ -230,35 +207,26 @@ describe('SegmentList', () => { // Focused state describe('Focused State', () => { it('should set focused index when currSegment matches', () => { - // Arrange mockCurrSegment = { segInfo: { id: 'seg-1' } } - // Act render(<SegmentList {...defaultProps} />) - // Assert expect(screen.getByTestId('focused-index')).toHaveTextContent('true') }) it('should set focused content when currSegment matches', () => { - // Arrange mockCurrSegment = { segInfo: { id: 'seg-1' } } - // Act render(<SegmentList {...defaultProps} />) - // Assert expect(screen.getByTestId('focused-content')).toHaveTextContent('true') }) it('should set focused when currChildChunk parent matches', () => { - // Arrange mockCurrChildChunk = { childChunkInfo: { segment_id: 'seg-1' } } - // Act render(<SegmentList {...defaultProps} />) - // Assert expect(screen.getByTestId('focused-index')).toHaveTextContent('true') }) }) @@ -266,50 +234,39 @@ describe('SegmentList', () => { // Clear filter describe('Clear Filter', () => { it('should call onClearFilter when clear filter button is clicked', async () => { - // Arrange const mockOnClearFilter = vi.fn() render(<SegmentList {...defaultProps} items={[]} onClearFilter={mockOnClearFilter} />) - // Act screen.getByTestId('clear-filter-btn').click() - // Assert expect(mockOnClearFilter).toHaveBeenCalled() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle single item without divider', () => { - // Arrange & Act render(<SegmentList {...defaultProps} items={[createMockSegment('seg-1', 'Content')]} />) - // Assert expect(screen.getByTestId('segment-card')).toBeInTheDocument() }) it('should handle multiple items with dividers', () => { - // Arrange const items = [ createMockSegment('seg-1', 'Content 1'), createMockSegment('seg-2', 'Content 2'), createMockSegment('seg-3', 'Content 3'), ] - // Act render(<SegmentList {...defaultProps} items={items} />) - // Assert expect(screen.getAllByTestId('segment-card')).toHaveLength(3) }) it('should maintain structure when rerendered with different items', () => { - // Arrange const { rerender } = render( <SegmentList {...defaultProps} items={[createMockSegment('seg-1', 'Content 1')]} />, ) - // Act rerender( <SegmentList {...defaultProps} @@ -320,7 +277,6 @@ describe('SegmentList', () => { />, ) - // Assert expect(screen.getAllByTestId('segment-card')).toHaveLength(2) }) }) @@ -328,7 +284,6 @@ describe('SegmentList', () => { // Checkbox Selection describe('Checkbox Selection', () => { it('should render checkbox for each segment', () => { - // Arrange & Act const { container } = render(<SegmentList {...defaultProps} />) // Assert - Checkbox component should exist @@ -337,7 +292,6 @@ describe('SegmentList', () => { }) it('should pass selectedSegmentIds to check state', () => { - // Arrange & Act const { container } = render(<SegmentList {...defaultProps} selectedSegmentIds={['seg-1']} />) // Assert - component should render with selected state @@ -345,7 +299,6 @@ describe('SegmentList', () => { }) it('should handle empty selectedSegmentIds', () => { - // Arrange & Act const { container } = render(<SegmentList {...defaultProps} selectedSegmentIds={[]} />) // Assert - component should render @@ -356,83 +309,63 @@ describe('SegmentList', () => { // Card Actions describe('Card Actions', () => { it('should call onClick when card is clicked', () => { - // Arrange const mockOnClick = vi.fn() render(<SegmentList {...defaultProps} onClick={mockOnClick} />) - // Act fireEvent.click(screen.getByTestId('card-click')) - // Assert expect(mockOnClick).toHaveBeenCalled() }) it('should call onChangeSwitch when switch button is clicked', async () => { - // Arrange const mockOnChangeSwitch = vi.fn().mockResolvedValue(undefined) render(<SegmentList {...defaultProps} onChangeSwitch={mockOnChangeSwitch} />) - // Act fireEvent.click(screen.getByTestId('switch-btn')) - // Assert expect(mockOnChangeSwitch).toHaveBeenCalledWith(true, 'seg-1') }) it('should call onDelete when delete button is clicked', async () => { - // Arrange const mockOnDelete = vi.fn().mockResolvedValue(undefined) render(<SegmentList {...defaultProps} onDelete={mockOnDelete} />) - // Act fireEvent.click(screen.getByTestId('delete-btn')) - // Assert expect(mockOnDelete).toHaveBeenCalledWith('seg-1') }) it('should call onDeleteChildChunk when delete child button is clicked', async () => { - // Arrange const mockOnDeleteChildChunk = vi.fn().mockResolvedValue(undefined) render(<SegmentList {...defaultProps} onDeleteChildChunk={mockOnDeleteChildChunk} />) - // Act fireEvent.click(screen.getByTestId('delete-child-btn')) - // Assert expect(mockOnDeleteChildChunk).toHaveBeenCalledWith('seg-1', 'child-1') }) it('should call handleAddNewChildChunk when add child button is clicked', () => { - // Arrange const mockHandleAddNewChildChunk = vi.fn() render(<SegmentList {...defaultProps} handleAddNewChildChunk={mockHandleAddNewChildChunk} />) - // Act fireEvent.click(screen.getByTestId('add-child-btn')) - // Assert expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('seg-1') }) it('should call onClickSlice when click slice button is clicked', () => { - // Arrange const mockOnClickSlice = vi.fn() render(<SegmentList {...defaultProps} onClickSlice={mockOnClickSlice} />) - // Act fireEvent.click(screen.getByTestId('click-slice-btn')) - // Assert expect(mockOnClickSlice).toHaveBeenCalledWith({ id: 'slice-1' }) }) it('should call onClick with edit mode when edit button is clicked', () => { - // Arrange const mockOnClick = vi.fn() render(<SegmentList {...defaultProps} onClick={mockOnClick} />) - // Act fireEvent.click(screen.getByTestId('edit-btn')) // Assert - onClick is called from onClickEdit with isEditMode=true diff --git a/web/app/components/datasets/documents/detail/completed/status-item.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/status-item.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/detail/completed/status-item.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/status-item.spec.tsx index a9114ffe79..da7e301e4d 100644 --- a/web/app/components/datasets/documents/detail/completed/status-item.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/status-item.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import StatusItem from './status-item' +import StatusItem from '../status-item' describe('StatusItem', () => { const defaultItem = { @@ -8,29 +8,22 @@ describe('StatusItem', () => { name: 'Test Status', } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<StatusItem item={defaultItem} selected={false} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render item name', () => { - // Arrange & Act render(<StatusItem item={defaultItem} selected={false} />) - // Assert expect(screen.getByText('Test Status')).toBeInTheDocument() }) it('should render with correct styling classes', () => { - // Arrange & Act const { container } = render(<StatusItem item={defaultItem} selected={false} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('items-center') @@ -38,10 +31,8 @@ describe('StatusItem', () => { }) }) - // Props tests describe('Props', () => { it('should show check icon when selected is true', () => { - // Arrange & Act const { container } = render(<StatusItem item={defaultItem} selected={true} />) // Assert - RiCheckLine icon should be present @@ -50,7 +41,6 @@ describe('StatusItem', () => { }) it('should not show check icon when selected is false', () => { - // Arrange & Act const { container } = render(<StatusItem item={defaultItem} selected={false} />) // Assert - RiCheckLine icon should not be present @@ -59,59 +49,44 @@ describe('StatusItem', () => { }) it('should render different item names', () => { - // Arrange & Act const item = { value: '2', name: 'Different Status' } render(<StatusItem item={item} selected={false} />) - // Assert expect(screen.getByText('Different Status')).toBeInTheDocument() }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently with same props', () => { - // Arrange & Act const { container: container1 } = render(<StatusItem item={defaultItem} selected={true} />) const { container: container2 } = render(<StatusItem item={defaultItem} selected={true} />) - // Assert expect(container1.textContent).toBe(container2.textContent) }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty item name', () => { - // Arrange const emptyItem = { value: '1', name: '' } - // Act const { container } = render(<StatusItem item={emptyItem} selected={false} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle special characters in item name', () => { - // Arrange const specialItem = { value: '1', name: 'Status <>&"' } - // Act render(<StatusItem item={specialItem} selected={false} />) - // Assert expect(screen.getByText('Status <>&"')).toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<StatusItem item={defaultItem} selected={false} />) - // Act rerender(<StatusItem item={defaultItem} selected={true} />) - // Assert expect(screen.getByText('Test Status')).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx similarity index 93% rename from web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx index a2fd94ee31..edf4b30922 100644 --- a/web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx @@ -1,10 +1,11 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import { DocumentContext } from '../../context' -import ActionButtons from './action-buttons' +import { DocumentContext } from '../../../context' +import ActionButtons from '../action-buttons' -// Mock useKeyPress from ahooks to capture and test callback functions +// Mock useKeyPress: required because tests capture registered callbacks +// via mockUseKeyPress to verify ESC and Ctrl+S keyboard shortcut behavior. const mockUseKeyPress = vi.fn() vi.mock('ahooks', () => ({ useKeyPress: (keys: string | string[], callback: (e: KeyboardEvent) => void, options?: object) => { @@ -51,10 +52,8 @@ describe('ActionButtons', () => { mockUseKeyPress.mockClear() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <ActionButtons handleCancel={vi.fn()} @@ -64,12 +63,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render cancel button', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -79,12 +76,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() }) it('should render save button', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -94,12 +89,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert expect(screen.getByText(/operation\.save/i)).toBeInTheDocument() }) it('should render ESC keyboard hint on cancel button', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -109,12 +102,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert expect(screen.getByText('ESC')).toBeInTheDocument() }) it('should render S keyboard hint on save button', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -124,15 +115,12 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert expect(screen.getByText('S')).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call handleCancel when cancel button is clicked', () => { - // Arrange const mockHandleCancel = vi.fn() render( <ActionButtons @@ -143,16 +131,13 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Act const cancelButton = screen.getAllByRole('button')[0] fireEvent.click(cancelButton) - // Assert expect(mockHandleCancel).toHaveBeenCalledTimes(1) }) it('should call handleSave when save button is clicked', () => { - // Arrange const mockHandleSave = vi.fn() render( <ActionButtons @@ -163,17 +148,14 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Act const buttons = screen.getAllByRole('button') const saveButton = buttons[buttons.length - 1] // Save button is last fireEvent.click(saveButton) - // Assert expect(mockHandleSave).toHaveBeenCalledTimes(1) }) it('should disable save button when loading is true', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -183,7 +165,6 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert const buttons = screen.getAllByRole('button') const saveButton = buttons[buttons.length - 1] expect(saveButton).toBeDisabled() @@ -193,7 +174,6 @@ describe('ActionButtons', () => { // Regeneration button tests describe('Regeneration Button', () => { it('should show regeneration button in parent-child paragraph mode for edit action', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -207,12 +187,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Assert expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument() }) it('should not show regeneration button when isChildChunk is true', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -226,12 +204,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Assert expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument() }) it('should not show regeneration button when showRegenerationButton is false', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -245,12 +221,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Assert expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument() }) it('should not show regeneration button when actionType is add', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -264,12 +238,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Assert expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument() }) it('should call handleRegeneration when regeneration button is clicked', () => { - // Arrange const mockHandleRegeneration = vi.fn() render( <ActionButtons @@ -284,17 +256,14 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Act const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button') if (regenerationButton) fireEvent.click(regenerationButton) - // Assert expect(mockHandleRegeneration).toHaveBeenCalledTimes(1) }) it('should disable regeneration button when loading is true', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -308,7 +277,6 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Assert const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button') expect(regenerationButton).toBeDisabled() }) @@ -370,7 +338,6 @@ describe('ActionButtons', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle missing context values gracefully', () => { // Arrange & Act & Assert - should not throw @@ -387,7 +354,6 @@ describe('ActionButtons', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render( <ActionButtons handleCancel={vi.fn()} @@ -397,7 +363,6 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Act rerender( <DocumentContext.Provider value={{}}> <ActionButtons @@ -408,7 +373,6 @@ describe('ActionButtons', () => { </DocumentContext.Provider>, ) - // Assert expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() expect(screen.getByText(/operation\.save/i)).toBeInTheDocument() }) @@ -417,7 +381,6 @@ describe('ActionButtons', () => { // Keyboard shortcuts tests via useKeyPress callbacks describe('Keyboard Shortcuts', () => { it('should display ctrl key hint on save button', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -433,7 +396,6 @@ describe('ActionButtons', () => { }) it('should call handleCancel and preventDefault when ESC key is pressed', () => { - // Arrange const mockHandleCancel = vi.fn() const mockPreventDefault = vi.fn() render( @@ -450,13 +412,11 @@ describe('ActionButtons', () => { expect(escCallback).toBeDefined() escCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent) - // Assert expect(mockPreventDefault).toHaveBeenCalledTimes(1) expect(mockHandleCancel).toHaveBeenCalledTimes(1) }) it('should call handleSave and preventDefault when Ctrl+S is pressed and not loading', () => { - // Arrange const mockHandleSave = vi.fn() const mockPreventDefault = vi.fn() render( @@ -473,13 +433,11 @@ describe('ActionButtons', () => { expect(ctrlSCallback).toBeDefined() ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent) - // Assert expect(mockPreventDefault).toHaveBeenCalledTimes(1) expect(mockHandleSave).toHaveBeenCalledTimes(1) }) it('should not call handleSave when Ctrl+S is pressed while loading', () => { - // Arrange const mockHandleSave = vi.fn() const mockPreventDefault = vi.fn() render( @@ -496,13 +454,11 @@ describe('ActionButtons', () => { expect(ctrlSCallback).toBeDefined() ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent) - // Assert expect(mockPreventDefault).toHaveBeenCalledTimes(1) expect(mockHandleSave).not.toHaveBeenCalled() }) it('should register useKeyPress with correct options for Ctrl+S', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} diff --git a/web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/add-another.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/add-another.spec.tsx index 6f76fb4f79..852119b854 100644 --- a/web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/add-another.spec.tsx @@ -1,26 +1,22 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import AddAnother from './add-another' +import AddAnother from '../add-another' describe('AddAnother', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the checkbox', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) @@ -31,7 +27,6 @@ describe('AddAnother', () => { }) it('should render the add another text', () => { - // Arrange & Act render(<AddAnother isChecked={false} onCheck={vi.fn()} />) // Assert - i18n key format @@ -39,12 +34,10 @@ describe('AddAnother', () => { }) it('should render with correct base styling classes', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('items-center') @@ -53,10 +46,8 @@ describe('AddAnother', () => { }) }) - // Props tests describe('Props', () => { it('should render unchecked state when isChecked is false', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) @@ -67,7 +58,6 @@ describe('AddAnother', () => { }) it('should render checked state when isChecked is true', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={true} onCheck={vi.fn()} />, ) @@ -78,7 +68,6 @@ describe('AddAnother', () => { }) it('should apply custom className', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} @@ -87,16 +76,13 @@ describe('AddAnother', () => { />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') }) }) - // User Interactions describe('User Interactions', () => { it('should call onCheck when checkbox is clicked', () => { - // Arrange const mockOnCheck = vi.fn() const { container } = render( <AddAnother isChecked={false} onCheck={mockOnCheck} />, @@ -107,12 +93,10 @@ describe('AddAnother', () => { if (checkbox) fireEvent.click(checkbox) - // Assert expect(mockOnCheck).toHaveBeenCalledTimes(1) }) it('should toggle checked state on multiple clicks', () => { - // Arrange const mockOnCheck = vi.fn() const { container, rerender } = render( <AddAnother isChecked={false} onCheck={mockOnCheck} />, @@ -126,68 +110,55 @@ describe('AddAnother', () => { fireEvent.click(checkbox) } - // Assert expect(mockOnCheck).toHaveBeenCalledTimes(2) }) }) - // Structure tests describe('Structure', () => { it('should render text with tertiary text color', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) - // Assert const textElement = container.querySelector('.text-text-tertiary') expect(textElement).toBeInTheDocument() }) it('should render text with xs medium font styling', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) - // Assert const textElement = container.querySelector('.system-xs-medium') expect(textElement).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const mockOnCheck = vi.fn() const { rerender, container } = render( <AddAnother isChecked={false} onCheck={mockOnCheck} />, ) - // Act rerender(<AddAnother isChecked={true} onCheck={mockOnCheck} />) - // Assert const checkbox = container.querySelector('.shrink-0') expect(checkbox).toBeInTheDocument() }) it('should handle rapid state changes', () => { - // Arrange const mockOnCheck = vi.fn() const { container } = render( <AddAnother isChecked={false} onCheck={mockOnCheck} />, ) - // Act const checkbox = container.querySelector('.shrink-0') if (checkbox) { for (let i = 0; i < 5; i++) fireEvent.click(checkbox) } - // Assert expect(mockOnCheck).toHaveBeenCalledTimes(5) }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/batch-action.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/batch-action.spec.tsx index 0c0190ed5d..eda7d3845c 100644 --- a/web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/batch-action.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import BatchAction from './batch-action' +import BatchAction from '../batch-action' describe('BatchAction', () => { beforeEach(() => { @@ -15,100 +15,75 @@ describe('BatchAction', () => { onCancel: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<BatchAction {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should display selected count', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.getByText('3')).toBeInTheDocument() }) it('should render enable button', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.getByText(/batchAction\.enable/i)).toBeInTheDocument() }) it('should render disable button', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.getByText(/batchAction\.disable/i)).toBeInTheDocument() }) it('should render delete button', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.getByText(/batchAction\.delete/i)).toBeInTheDocument() }) it('should render cancel button', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.getByText(/batchAction\.cancel/i)).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onBatchEnable when enable button is clicked', () => { - // Arrange const mockOnBatchEnable = vi.fn() render(<BatchAction {...defaultProps} onBatchEnable={mockOnBatchEnable} />) - // Act fireEvent.click(screen.getByText(/batchAction\.enable/i)) - // Assert expect(mockOnBatchEnable).toHaveBeenCalledTimes(1) }) it('should call onBatchDisable when disable button is clicked', () => { - // Arrange const mockOnBatchDisable = vi.fn() render(<BatchAction {...defaultProps} onBatchDisable={mockOnBatchDisable} />) - // Act fireEvent.click(screen.getByText(/batchAction\.disable/i)) - // Assert expect(mockOnBatchDisable).toHaveBeenCalledTimes(1) }) it('should call onCancel when cancel button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() render(<BatchAction {...defaultProps} onCancel={mockOnCancel} />) - // Act fireEvent.click(screen.getByText(/batchAction\.cancel/i)) - // Assert expect(mockOnCancel).toHaveBeenCalledTimes(1) }) it('should show delete confirmation dialog when delete button is clicked', () => { - // Arrange render(<BatchAction {...defaultProps} />) - // Act fireEvent.click(screen.getByText(/batchAction\.delete/i)) // Assert - Confirm dialog should appear @@ -116,7 +91,6 @@ describe('BatchAction', () => { }) it('should call onBatchDelete when confirm is clicked in delete dialog', async () => { - // Arrange const mockOnBatchDelete = vi.fn().mockResolvedValue(undefined) render(<BatchAction {...defaultProps} onBatchDelete={mockOnBatchDelete} />) @@ -127,7 +101,6 @@ describe('BatchAction', () => { const confirmButton = screen.getByText(/operation\.sure/i) fireEvent.click(confirmButton) - // Assert await waitFor(() => { expect(mockOnBatchDelete).toHaveBeenCalledTimes(1) }) @@ -137,98 +110,74 @@ describe('BatchAction', () => { // Optional props tests describe('Optional Props', () => { it('should render download button when onBatchDownload is provided', () => { - // Arrange & Act render(<BatchAction {...defaultProps} onBatchDownload={vi.fn()} />) - // Assert expect(screen.getByText(/batchAction\.download/i)).toBeInTheDocument() }) it('should not render download button when onBatchDownload is not provided', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.queryByText(/batchAction\.download/i)).not.toBeInTheDocument() }) it('should render archive button when onArchive is provided', () => { - // Arrange & Act render(<BatchAction {...defaultProps} onArchive={vi.fn()} />) - // Assert expect(screen.getByText(/batchAction\.archive/i)).toBeInTheDocument() }) it('should render metadata button when onEditMetadata is provided', () => { - // Arrange & Act render(<BatchAction {...defaultProps} onEditMetadata={vi.fn()} />) - // Assert expect(screen.getByText(/metadata\.metadata/i)).toBeInTheDocument() }) it('should render re-index button when onBatchReIndex is provided', () => { - // Arrange & Act render(<BatchAction {...defaultProps} onBatchReIndex={vi.fn()} />) - // Assert expect(screen.getByText(/batchAction\.reIndex/i)).toBeInTheDocument() }) it('should call onBatchDownload when download button is clicked', () => { - // Arrange const mockOnBatchDownload = vi.fn() render(<BatchAction {...defaultProps} onBatchDownload={mockOnBatchDownload} />) - // Act fireEvent.click(screen.getByText(/batchAction\.download/i)) - // Assert expect(mockOnBatchDownload).toHaveBeenCalledTimes(1) }) it('should call onArchive when archive button is clicked', () => { - // Arrange const mockOnArchive = vi.fn() render(<BatchAction {...defaultProps} onArchive={mockOnArchive} />) - // Act fireEvent.click(screen.getByText(/batchAction\.archive/i)) - // Assert expect(mockOnArchive).toHaveBeenCalledTimes(1) }) it('should call onEditMetadata when metadata button is clicked', () => { - // Arrange const mockOnEditMetadata = vi.fn() render(<BatchAction {...defaultProps} onEditMetadata={mockOnEditMetadata} />) - // Act fireEvent.click(screen.getByText(/metadata\.metadata/i)) - // Assert expect(mockOnEditMetadata).toHaveBeenCalledTimes(1) }) it('should call onBatchReIndex when re-index button is clicked', () => { - // Arrange const mockOnBatchReIndex = vi.fn() render(<BatchAction {...defaultProps} onBatchReIndex={mockOnBatchReIndex} />) - // Act fireEvent.click(screen.getByText(/batchAction\.reIndex/i)) - // Assert expect(mockOnBatchReIndex).toHaveBeenCalledTimes(1) }) it('should apply custom className', () => { - // Arrange & Act const { container } = render(<BatchAction {...defaultProps} className="custom-class" />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') }) @@ -237,40 +186,30 @@ describe('BatchAction', () => { // Selected count display tests describe('Selected Count', () => { it('should display correct count for single selection', () => { - // Arrange & Act render(<BatchAction {...defaultProps} selectedIds={['1']} />) - // Assert expect(screen.getByText('1')).toBeInTheDocument() }) it('should display correct count for multiple selections', () => { - // Arrange & Act render(<BatchAction {...defaultProps} selectedIds={['1', '2', '3', '4', '5']} />) - // Assert expect(screen.getByText('5')).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<BatchAction {...defaultProps} />) - // Act rerender(<BatchAction {...defaultProps} selectedIds={['1', '2']} />) - // Assert expect(screen.getByText('2')).toBeInTheDocument() }) it('should handle empty selectedIds array', () => { - // Arrange & Act render(<BatchAction {...defaultProps} selectedIds={[]} />) - // Assert expect(screen.getByText('0')).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/chunk-content.spec.tsx similarity index 91% rename from web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/chunk-content.spec.tsx index 01c1be919c..115db9ad61 100644 --- a/web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/chunk-content.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import ChunkContent from './chunk-content' +import ChunkContent from '../chunk-content' // Mock ResizeObserver const OriginalResizeObserver = globalThis.ResizeObserver @@ -30,27 +30,21 @@ describe('ChunkContent', () => { docForm: ChunkingMode.text, } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<ChunkContent {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render textarea in edit mode with text docForm', () => { - // Arrange & Act render(<ChunkContent {...defaultProps} isEditMode={true} />) - // Assert const textarea = screen.getByRole('textbox') expect(textarea).toBeInTheDocument() }) it('should render Markdown content in view mode with text docForm', () => { - // Arrange & Act const { container } = render(<ChunkContent {...defaultProps} isEditMode={false} />) // Assert - In view mode, textarea should not be present, Markdown renders instead @@ -61,7 +55,6 @@ describe('ChunkContent', () => { // QA mode tests describe('QA Mode', () => { it('should render QA layout when docForm is qa', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -78,7 +71,6 @@ describe('ChunkContent', () => { }) it('should display question value in QA mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -90,13 +82,11 @@ describe('ChunkContent', () => { />, ) - // Assert const textareas = screen.getAllByRole('textbox') expect(textareas[0]).toHaveValue('My question') }) it('should display answer value in QA mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -108,16 +98,13 @@ describe('ChunkContent', () => { />, ) - // Assert const textareas = screen.getAllByRole('textbox') expect(textareas[1]).toHaveValue('My answer') }) }) - // User Interactions describe('User Interactions', () => { it('should call onQuestionChange when textarea value changes in text mode', () => { - // Arrange const mockOnQuestionChange = vi.fn() render( <ChunkContent @@ -127,16 +114,13 @@ describe('ChunkContent', () => { />, ) - // Act const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: 'New content' } }) - // Assert expect(mockOnQuestionChange).toHaveBeenCalledWith('New content') }) it('should call onQuestionChange when question textarea changes in QA mode', () => { - // Arrange const mockOnQuestionChange = vi.fn() render( <ChunkContent @@ -148,16 +132,13 @@ describe('ChunkContent', () => { />, ) - // Act const textareas = screen.getAllByRole('textbox') fireEvent.change(textareas[0], { target: { value: 'New question' } }) - // Assert expect(mockOnQuestionChange).toHaveBeenCalledWith('New question') }) it('should call onAnswerChange when answer textarea changes in QA mode', () => { - // Arrange const mockOnAnswerChange = vi.fn() render( <ChunkContent @@ -169,16 +150,13 @@ describe('ChunkContent', () => { />, ) - // Act const textareas = screen.getAllByRole('textbox') fireEvent.change(textareas[1], { target: { value: 'New answer' } }) - // Assert expect(mockOnAnswerChange).toHaveBeenCalledWith('New answer') }) it('should disable textarea when isEditMode is false in text mode', () => { - // Arrange & Act const { container } = render( <ChunkContent {...defaultProps} isEditMode={false} />, ) @@ -188,7 +166,6 @@ describe('ChunkContent', () => { }) it('should disable textareas when isEditMode is false in QA mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -199,7 +176,6 @@ describe('ChunkContent', () => { />, ) - // Assert const textareas = screen.getAllByRole('textbox') textareas.forEach((textarea) => { expect(textarea).toBeDisabled() @@ -210,15 +186,12 @@ describe('ChunkContent', () => { // DocForm variations describe('DocForm Variations', () => { it('should handle ChunkingMode.text', () => { - // Arrange & Act render(<ChunkContent {...defaultProps} docForm={ChunkingMode.text} isEditMode={true} />) - // Assert expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should handle ChunkingMode.qa', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -235,7 +208,6 @@ describe('ChunkContent', () => { }) it('should handle ChunkingMode.parentChild similar to text mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -249,10 +221,8 @@ describe('ChunkContent', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty question', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -261,13 +231,11 @@ describe('ChunkContent', () => { />, ) - // Assert const textarea = screen.getByRole('textbox') expect(textarea).toHaveValue('') }) it('should handle empty answer in QA mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -279,13 +247,11 @@ describe('ChunkContent', () => { />, ) - // Assert const textareas = screen.getAllByRole('textbox') expect(textareas[1]).toHaveValue('') }) it('should handle undefined answer in QA mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -299,17 +265,14 @@ describe('ChunkContent', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render( <ChunkContent {...defaultProps} question="Initial" isEditMode={true} />, ) - // Act rerender( <ChunkContent {...defaultProps} question="Updated" isEditMode={true} />, ) - // Assert const textarea = screen.getByRole('textbox') expect(textarea).toHaveValue('Updated') }) diff --git a/web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/dot.spec.tsx similarity index 82% rename from web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/dot.spec.tsx index af8c981bf5..2b8b43fae9 100644 --- a/web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/dot.spec.tsx @@ -1,59 +1,45 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Dot from './dot' +import Dot from '../dot' describe('Dot', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<Dot />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the dot character', () => { - // Arrange & Act render(<Dot />) - // Assert expect(screen.getByText('·')).toBeInTheDocument() }) it('should render with correct styling classes', () => { - // Arrange & Act const { container } = render(<Dot />) - // Assert const dotElement = container.firstChild as HTMLElement expect(dotElement).toHaveClass('system-xs-medium') expect(dotElement).toHaveClass('text-text-quaternary') }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently across multiple renders', () => { - // Arrange & Act const { container: container1 } = render(<Dot />) const { container: container2 } = render(<Dot />) - // Assert expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent) }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<Dot />) - // Act rerender(<Dot />) - // Assert expect(screen.getByText('·')).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx new file mode 100644 index 0000000000..d9a87ea3e4 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Drawer from '../drawer' + +let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined + +// Mock useKeyPress: required because tests capture the registered callback +// and invoke it directly to verify ESC key handling behavior. +vi.mock('ahooks', () => ({ + useKeyPress: vi.fn((_key: string, cb: (e: KeyboardEvent) => void) => { + capturedKeyPressCallback = cb + }), +})) + +vi.mock('../..', () => ({ + useSegmentListContext: (selector: (state: { + currSegment: { showModal: boolean } + currChildChunk: { showModal: boolean } + }) => unknown) => + selector({ + currSegment: { showModal: false }, + currChildChunk: { showModal: false }, + }), +})) + +describe('Drawer', () => { + const defaultProps = { + open: true, + onClose: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + capturedKeyPressCallback = undefined + }) + + describe('Rendering', () => { + it('should return null when open is false', () => { + const { container } = render( + <Drawer open={false} onClose={vi.fn()}> + <span>Content</span> + </Drawer>, + ) + + expect(container.innerHTML).toBe('') + expect(screen.queryByText('Content')).not.toBeInTheDocument() + }) + + it('should render children in portal when open is true', () => { + render( + <Drawer {...defaultProps}> + <span>Drawer content</span> + </Drawer>, + ) + + expect(screen.getByText('Drawer content')).toBeInTheDocument() + }) + + it('should render dialog with role="dialog"', () => { + render( + <Drawer {...defaultProps}> + <span>Content</span> + </Drawer>, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + }) + + // Overlay visibility + describe('Overlay', () => { + it('should show overlay when showOverlay is true', () => { + render( + <Drawer {...defaultProps} showOverlay={true}> + <span>Content</span> + </Drawer>, + ) + + const overlay = document.querySelector('[aria-hidden="true"]') + expect(overlay).toBeInTheDocument() + }) + + it('should hide overlay when showOverlay is false', () => { + render( + <Drawer {...defaultProps} showOverlay={false}> + <span>Content</span> + </Drawer>, + ) + + const overlay = document.querySelector('[aria-hidden="true"]') + expect(overlay).not.toBeInTheDocument() + }) + }) + + // aria-modal attribute + describe('aria-modal', () => { + it('should set aria-modal="true" when modal is true', () => { + render( + <Drawer {...defaultProps} modal={true}> + <span>Content</span> + </Drawer>, + ) + + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true') + }) + + it('should set aria-modal="false" when modal is false', () => { + render( + <Drawer {...defaultProps} modal={false}> + <span>Content</span> + </Drawer>, + ) + + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false') + }) + }) + + // ESC key handling + describe('ESC Key', () => { + it('should call onClose when ESC is pressed and drawer is open', () => { + const onClose = vi.fn() + render( + <Drawer open={true} onClose={onClose}> + <span>Content</span> + </Drawer>, + ) + + expect(capturedKeyPressCallback).toBeDefined() + const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent + capturedKeyPressCallback!(fakeEvent) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/empty.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/empty.spec.tsx index 6feb9ea4c0..f957789926 100644 --- a/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/empty.spec.tsx @@ -1,24 +1,20 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Empty from './empty' +import Empty from '../empty' describe('Empty', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the file list icon', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) // Assert - RiFileList2Line icon should be rendered @@ -27,7 +23,6 @@ describe('Empty', () => { }) it('should render empty message text', () => { - // Arrange & Act render(<Empty onClearFilter={vi.fn()} />) // Assert - i18n key format: datasetDocuments:segment.empty @@ -35,15 +30,12 @@ describe('Empty', () => { }) it('should render clear filter button', () => { - // Arrange & Act render(<Empty onClearFilter={vi.fn()} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should render background empty cards', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) // Assert - should have 10 background cards @@ -52,25 +44,19 @@ describe('Empty', () => { }) }) - // User Interactions describe('User Interactions', () => { it('should call onClearFilter when clear filter button is clicked', () => { - // Arrange const mockOnClearFilter = vi.fn() render(<Empty onClearFilter={mockOnClearFilter} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnClearFilter).toHaveBeenCalledTimes(1) }) }) - // Structure tests describe('Structure', () => { it('should render the decorative lines', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) // Assert - there should be 4 Line components (SVG elements) @@ -79,73 +65,56 @@ describe('Empty', () => { }) it('should render mask overlay', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) - // Assert const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg') expect(maskElement).toBeInTheDocument() }) it('should render icon container with proper styling', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) - // Assert const iconContainer = container.querySelector('.shadow-lg') expect(iconContainer).toBeInTheDocument() }) it('should render clear filter button with accent text styling', () => { - // Arrange & Act render(<Empty onClearFilter={vi.fn()} />) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('text-text-accent') }) }) - // Props tests describe('Props', () => { it('should accept onClearFilter callback prop', () => { - // Arrange const mockCallback = vi.fn() - // Act render(<Empty onClearFilter={mockCallback} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockCallback).toHaveBeenCalled() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle multiple clicks on clear filter button', () => { - // Arrange const mockOnClearFilter = vi.fn() render(<Empty onClearFilter={mockOnClearFilter} />) - // Act const button = screen.getByRole('button') fireEvent.click(button) fireEvent.click(button) fireEvent.click(button) - // Assert expect(mockOnClearFilter).toHaveBeenCalledTimes(3) }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render(<Empty onClearFilter={vi.fn()} />) - // Act rerender(<Empty onClearFilter={vi.fn()} />) - // Assert const emptyCards = container.querySelectorAll('.bg-background-section-burn') expect(emptyCards).toHaveLength(10) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx index 24def69f7a..ae870c8e1c 100644 --- a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import FullScreenDrawer from './full-screen-drawer' +import FullScreenDrawer from '../full-screen-drawer' // Mock the Drawer component since it has high complexity -vi.mock('./drawer', () => ({ +vi.mock('../drawer', () => ({ default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => { if (!open) return null @@ -28,147 +28,123 @@ describe('FullScreenDrawer', () => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing when open', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert expect(screen.getByTestId('drawer-mock')).toBeInTheDocument() }) it('should not render when closed', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={false} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument() }) it('should render children content', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Test Content</div> </FullScreenDrawer>, ) - // Assert expect(screen.getByText('Test Content')).toBeInTheDocument() }) }) - // Props tests describe('Props', () => { it('should pass fullScreen=true to Drawer with full width class', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={true}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-panel-class')).toContain('w-full') }) it('should pass fullScreen=false to Drawer with fixed width class', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]') }) it('should pass showOverlay prop with default true', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-show-overlay')).toBe('true') }) it('should pass showOverlay=false when specified', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false} showOverlay={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-show-overlay')).toBe('false') }) it('should pass needCheckChunks prop with default false', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-need-check-chunks')).toBe('false') }) it('should pass needCheckChunks=true when specified', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false} needCheckChunks={true}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-need-check-chunks')).toBe('true') }) it('should pass modal prop with default false', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-modal')).toBe('false') }) it('should pass modal=true when specified', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false} modal={true}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-modal')).toBe('true') }) @@ -177,14 +153,12 @@ describe('FullScreenDrawer', () => { // Styling tests describe('Styling', () => { it('should apply panel content classes for non-fullScreen mode', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') const contentClass = drawer.getAttribute('data-panel-content-class') expect(contentClass).toContain('bg-components-panel-bg') @@ -192,14 +166,12 @@ describe('FullScreenDrawer', () => { }) it('should apply panel content classes without border for fullScreen mode', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={true}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') const contentClass = drawer.getAttribute('data-panel-content-class') expect(contentClass).toContain('bg-components-panel-bg') @@ -207,7 +179,6 @@ describe('FullScreenDrawer', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined onClose gracefully', () => { // Arrange & Act & Assert - should not throw @@ -221,26 +192,22 @@ describe('FullScreenDrawer', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Act rerender( <FullScreenDrawer isOpen={true} fullScreen={true}> <div>Updated Content</div> </FullScreenDrawer>, ) - // Assert expect(screen.getByText('Updated Content')).toBeInTheDocument() }) it('should handle toggle between open and closed states', () => { - // Arrange const { rerender } = render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> @@ -248,14 +215,12 @@ describe('FullScreenDrawer', () => { ) expect(screen.getByTestId('drawer-mock')).toBeInTheDocument() - // Act rerender( <FullScreenDrawer isOpen={false} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/keywords.spec.tsx similarity index 91% rename from web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/keywords.spec.tsx index a11f98e3bb..32165e3278 100644 --- a/web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/keywords.spec.tsx @@ -1,16 +1,14 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Keywords from './keywords' +import Keywords from '../keywords' describe('Keywords', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -18,12 +16,10 @@ describe('Keywords', () => { />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the keywords label', () => { - // Arrange & Act render( <Keywords keywords={['test']} @@ -36,7 +32,6 @@ describe('Keywords', () => { }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -44,17 +39,14 @@ describe('Keywords', () => { />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('flex-col') }) }) - // Props tests describe('Props', () => { it('should display dash when no keywords and actionType is view', () => { - // Arrange & Act render( <Keywords segInfo={{ id: '1', keywords: [] }} @@ -64,12 +56,10 @@ describe('Keywords', () => { />, ) - // Assert expect(screen.getByText('-')).toBeInTheDocument() }) it('should not display dash when actionType is edit', () => { - // Arrange & Act render( <Keywords segInfo={{ id: '1', keywords: [] }} @@ -79,12 +69,10 @@ describe('Keywords', () => { />, ) - // Assert expect(screen.queryByText('-')).not.toBeInTheDocument() }) it('should not display dash when actionType is add', () => { - // Arrange & Act render( <Keywords segInfo={{ id: '1', keywords: [] }} @@ -94,12 +82,10 @@ describe('Keywords', () => { />, ) - // Assert expect(screen.queryByText('-')).not.toBeInTheDocument() }) it('should apply custom className', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -108,13 +94,11 @@ describe('Keywords', () => { />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') }) it('should use default actionType of view', () => { - // Arrange & Act render( <Keywords segInfo={{ id: '1', keywords: [] }} @@ -128,10 +112,8 @@ describe('Keywords', () => { }) }) - // Structure tests describe('Structure', () => { it('should render label with uppercase styling', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -139,13 +121,11 @@ describe('Keywords', () => { />, ) - // Assert const labelElement = container.querySelector('.system-xs-medium-uppercase') expect(labelElement).toBeInTheDocument() }) it('should render keywords container with overflow handling', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -153,13 +133,11 @@ describe('Keywords', () => { />, ) - // Assert const keywordsContainer = container.querySelector('.overflow-auto') expect(keywordsContainer).toBeInTheDocument() }) it('should render keywords container with max height', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -167,7 +145,6 @@ describe('Keywords', () => { />, ) - // Assert const keywordsContainer = container.querySelector('.max-h-\\[200px\\]') expect(keywordsContainer).toBeInTheDocument() }) @@ -176,7 +153,6 @@ describe('Keywords', () => { // Edit mode tests describe('Edit Mode', () => { it('should render TagInput component when keywords exist', () => { - // Arrange & Act const { container } = render( <Keywords segInfo={{ id: '1', keywords: ['keyword1', 'keyword2'] }} @@ -192,10 +168,8 @@ describe('Keywords', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty keywords array in view mode without segInfo keywords', () => { - // Arrange & Act const { container } = render( <Keywords keywords={[]} @@ -209,7 +183,6 @@ describe('Keywords', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render( <Keywords segInfo={{ id: '1', keywords: ['test'] }} @@ -218,7 +191,6 @@ describe('Keywords', () => { />, ) - // Act rerender( <Keywords segInfo={{ id: '1', keywords: ['test', 'new'] }} @@ -227,12 +199,10 @@ describe('Keywords', () => { />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle segInfo with undefined keywords showing dash in view mode', () => { - // Arrange & Act render( <Keywords segInfo={{ id: '1' }} @@ -250,7 +220,6 @@ describe('Keywords', () => { // TagInput callback tests describe('TagInput Callback', () => { it('should call onKeywordsChange when keywords are modified', () => { - // Arrange const mockOnKeywordsChange = vi.fn() render( <Keywords @@ -267,7 +236,6 @@ describe('Keywords', () => { }) it('should disable add when isEditMode is false', () => { - // Arrange & Act const { container } = render( <Keywords segInfo={{ id: '1', keywords: ['test'] }} @@ -283,7 +251,6 @@ describe('Keywords', () => { }) it('should disable remove when only one keyword exists in edit mode', () => { - // Arrange & Act const { container } = render( <Keywords segInfo={{ id: '1', keywords: ['only-one'] }} @@ -299,7 +266,6 @@ describe('Keywords', () => { }) it('should allow remove when multiple keywords exist in edit mode', () => { - // Arrange & Act const { container } = render( <Keywords segInfo={{ id: '1', keywords: ['first', 'second'] }} diff --git a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/regeneration-modal.spec.tsx similarity index 91% rename from web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/regeneration-modal.spec.tsx index bd46dfdd62..719e2867b7 100644 --- a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/regeneration-modal.spec.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { EventEmitterContextProvider, useEventEmitterContextContext } from '@/context/event-emitter' -import RegenerationModal from './regeneration-modal' +import RegenerationModal from '../regeneration-modal' // Store emit function for triggering events in tests let emitFunction: ((v: string) => void) | null = null @@ -44,18 +44,14 @@ describe('RegenerationModal', () => { onClose: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing when isShow is true', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Assert expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument() }) it('should not render content when isShow is false', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} isShow={false} />, { wrapper: createWrapper() }) // Assert - Modal container might exist but content should not be visible @@ -63,53 +59,40 @@ describe('RegenerationModal', () => { }) it('should render confirmation message', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Assert expect(screen.getByText(/segment\.regenerationConfirmMessage/i)).toBeInTheDocument() }) it('should render cancel button in default state', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Assert expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() }) it('should render regenerate button in default state', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Assert expect(screen.getByText(/operation\.regenerate/i)).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when cancel button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() render(<RegenerationModal {...defaultProps} onCancel={mockOnCancel} />, { wrapper: createWrapper() }) - // Act fireEvent.click(screen.getByText(/operation\.cancel/i)) - // Assert expect(mockOnCancel).toHaveBeenCalledTimes(1) }) it('should call onConfirm when regenerate button is clicked', () => { - // Arrange const mockOnConfirm = vi.fn() render(<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />, { wrapper: createWrapper() }) - // Act fireEvent.click(screen.getByText(/operation\.regenerate/i)) - // Assert expect(mockOnConfirm).toHaveBeenCalledTimes(1) }) }) @@ -117,45 +100,37 @@ describe('RegenerationModal', () => { // Modal content states - these would require event emitter manipulation describe('Modal States', () => { it('should show default content initially', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Assert expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument() expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle toggling isShow prop', () => { - // Arrange const { rerender } = render( <RegenerationModal {...defaultProps} isShow={true} />, { wrapper: createWrapper() }, ) expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument() - // Act rerender( <TestWrapper> <RegenerationModal {...defaultProps} isShow={false} /> </TestWrapper>, ) - // Assert expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument() }) it('should maintain handlers when rerendered', () => { - // Arrange const mockOnConfirm = vi.fn() const { rerender } = render( <RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />, { wrapper: createWrapper() }, ) - // Act rerender( <TestWrapper> <RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} /> @@ -163,56 +138,45 @@ describe('RegenerationModal', () => { ) fireEvent.click(screen.getByText(/operation\.regenerate/i)) - // Assert expect(mockOnConfirm).toHaveBeenCalledTimes(1) }) }) - // Loading state describe('Loading State', () => { it('should show regenerating content when update-segment event is emitted', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) emitFunction('update-segment') }) - // Assert await waitFor(() => { expect(screen.getByText(/segment\.regeneratingTitle/i)).toBeInTheDocument() }) }) it('should show regenerating message during loading', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) emitFunction('update-segment') }) - // Assert await waitFor(() => { expect(screen.getByText(/segment\.regeneratingMessage/i)).toBeInTheDocument() }) }) it('should disable regenerate button during loading', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) emitFunction('update-segment') }) - // Assert await waitFor(() => { const button = screen.getByText(/operation\.regenerate/i).closest('button') expect(button).toBeDisabled() @@ -223,7 +187,6 @@ describe('RegenerationModal', () => { // Success state describe('Success State', () => { it('should show success content when update-segment-success event is emitted followed by done', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) // Act - trigger loading then success then done @@ -235,17 +198,14 @@ describe('RegenerationModal', () => { } }) - // Assert await waitFor(() => { expect(screen.getByText(/segment\.regenerationSuccessTitle/i)).toBeInTheDocument() }) }) it('should show success message when completed', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) { emitFunction('update-segment') @@ -254,17 +214,14 @@ describe('RegenerationModal', () => { } }) - // Assert await waitFor(() => { expect(screen.getByText(/segment\.regenerationSuccessMessage/i)).toBeInTheDocument() }) }) it('should show close button with countdown in success state', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) { emitFunction('update-segment') @@ -273,18 +230,15 @@ describe('RegenerationModal', () => { } }) - // Assert await waitFor(() => { expect(screen.getByText(/operation\.close/i)).toBeInTheDocument() }) }) it('should call onClose when close button is clicked in success state', async () => { - // Arrange const mockOnClose = vi.fn() render(<RegenerationModal {...defaultProps} onClose={mockOnClose} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) { emitFunction('update-segment') @@ -299,7 +253,6 @@ describe('RegenerationModal', () => { fireEvent.click(screen.getByText(/operation\.close/i)) - // Assert expect(mockOnClose).toHaveBeenCalled() }) }) @@ -307,7 +260,6 @@ describe('RegenerationModal', () => { // State transitions describe('State Transitions', () => { it('should return to default content when update fails (no success event)', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) // Act - trigger loading then done without success diff --git a/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/segment-index-tag.spec.tsx similarity index 85% rename from web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/segment-index-tag.spec.tsx index 8d0bf89636..4e73c86209 100644 --- a/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/segment-index-tag.spec.tsx @@ -1,42 +1,33 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import SegmentIndexTag from './segment-index-tag' +import SegmentIndexTag from '../segment-index-tag' describe('SegmentIndexTag', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the Chunk icon', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert const icon = container.querySelector('.h-3.w-3') expect(icon).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('items-center') }) }) - // Props tests describe('Props', () => { it('should render position ID with default prefix', () => { - // Arrange & Act render(<SegmentIndexTag positionId={5} />) // Assert - default prefix is 'Chunk' @@ -44,148 +35,116 @@ describe('SegmentIndexTag', () => { }) it('should render position ID without padding for two-digit numbers', () => { - // Arrange & Act render(<SegmentIndexTag positionId={15} />) - // Assert expect(screen.getByText('Chunk-15')).toBeInTheDocument() }) it('should render position ID without padding for three-digit numbers', () => { - // Arrange & Act render(<SegmentIndexTag positionId={123} />) - // Assert expect(screen.getByText('Chunk-123')).toBeInTheDocument() }) it('should render custom label when provided', () => { - // Arrange & Act render(<SegmentIndexTag positionId={1} label="Custom Label" />) - // Assert expect(screen.getByText('Custom Label')).toBeInTheDocument() }) it('should use custom labelPrefix', () => { - // Arrange & Act render(<SegmentIndexTag positionId={3} labelPrefix="Segment" />) - // Assert expect(screen.getByText('Segment-03')).toBeInTheDocument() }) it('should apply custom className', () => { - // Arrange & Act const { container } = render( <SegmentIndexTag positionId={1} className="custom-class" />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') }) it('should apply custom iconClassName', () => { - // Arrange & Act const { container } = render( <SegmentIndexTag positionId={1} iconClassName="custom-icon-class" />, ) - // Assert const icon = container.querySelector('.custom-icon-class') expect(icon).toBeInTheDocument() }) it('should apply custom labelClassName', () => { - // Arrange & Act const { container } = render( <SegmentIndexTag positionId={1} labelClassName="custom-label-class" />, ) - // Assert const label = container.querySelector('.custom-label-class') expect(label).toBeInTheDocument() }) it('should handle string positionId', () => { - // Arrange & Act render(<SegmentIndexTag positionId="7" />) - // Assert expect(screen.getByText('Chunk-07')).toBeInTheDocument() }) }) - // Memoization tests describe('Memoization', () => { it('should compute localPositionId based on positionId and labelPrefix', () => { - // Arrange & Act const { rerender } = render(<SegmentIndexTag positionId={1} />) expect(screen.getByText('Chunk-01')).toBeInTheDocument() // Act - change positionId rerender(<SegmentIndexTag positionId={2} />) - // Assert expect(screen.getByText('Chunk-02')).toBeInTheDocument() }) it('should update when labelPrefix changes', () => { - // Arrange & Act const { rerender } = render(<SegmentIndexTag positionId={1} labelPrefix="Chunk" />) expect(screen.getByText('Chunk-01')).toBeInTheDocument() // Act - change labelPrefix rerender(<SegmentIndexTag positionId={1} labelPrefix="Part" />) - // Assert expect(screen.getByText('Part-01')).toBeInTheDocument() }) }) - // Structure tests describe('Structure', () => { it('should render icon with tertiary text color', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert const icon = container.querySelector('.text-text-tertiary') expect(icon).toBeInTheDocument() }) it('should render label with xs medium font styling', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert const label = container.querySelector('.system-xs-medium') expect(label).toBeInTheDocument() }) it('should render icon with margin-right spacing', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert const icon = container.querySelector('.mr-0\\.5') expect(icon).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle positionId of 0', () => { - // Arrange & Act render(<SegmentIndexTag positionId={0} />) - // Assert expect(screen.getByText('Chunk-00')).toBeInTheDocument() }) it('should handle undefined positionId', () => { - // Arrange & Act render(<SegmentIndexTag />) // Assert - should display 'Chunk-undefined' or similar @@ -193,22 +152,17 @@ describe('SegmentIndexTag', () => { }) it('should prioritize label over computed positionId', () => { - // Arrange & Act render(<SegmentIndexTag positionId={99} label="Override" />) - // Assert expect(screen.getByText('Override')).toBeInTheDocument() expect(screen.queryByText('Chunk-99')).not.toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render(<SegmentIndexTag positionId={1} />) - // Act rerender(<SegmentIndexTag positionId={1} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-label.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-label.spec.tsx new file mode 100644 index 0000000000..0615b9790d --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-label.spec.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import SummaryLabel from '../summary-label' + +describe('SummaryLabel', () => { + it('should render summary heading', () => { + render(<SummaryLabel summary="This is a summary" />) + expect(screen.getByText('datasetDocuments.segment.summary')).toBeInTheDocument() + }) + + it('should render summary text', () => { + render(<SummaryLabel summary="This is a summary" />) + expect(screen.getByText('This is a summary')).toBeInTheDocument() + }) + + it('should render without summary text', () => { + render(<SummaryLabel />) + expect(screen.getByText('datasetDocuments.segment.summary')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx new file mode 100644 index 0000000000..76724f3480 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx @@ -0,0 +1,27 @@ +import type * as React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import SummaryStatus from '../summary-status' + +vi.mock('@/app/components/base/badge', () => ({ + default: ({ children }: { children: React.ReactNode }) => <span data-testid="badge">{children}</span>, +})) +vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({ + SearchLinesSparkle: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="sparkle-icon" {...props} />, +})) +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, +})) + +describe('SummaryStatus', () => { + it('should render badge for SUMMARIZING status', () => { + render(<SummaryStatus status="SUMMARIZING" />) + expect(screen.getByTestId('badge')).toBeInTheDocument() + expect(screen.getByText('datasetDocuments.list.summary.generating')).toBeInTheDocument() + }) + + it('should not render badge for other statuses', () => { + render(<SummaryStatus status="COMPLETED" />) + expect(screen.queryByTestId('badge')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx new file mode 100644 index 0000000000..f4478f6b37 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx @@ -0,0 +1,42 @@ +import type * as React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SummaryText from '../summary-text' + +vi.mock('react-textarea-autosize', () => ({ + default: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea data-testid="textarea" {...props} />, +})) + +describe('SummaryText', () => { + const onChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render summary heading', () => { + render(<SummaryText />) + expect(screen.getByText('datasetDocuments.segment.summary')).toBeInTheDocument() + }) + + it('should render value in textarea', () => { + render(<SummaryText value="My summary" onChange={onChange} />) + expect(screen.getByTestId('textarea')).toHaveValue('My summary') + }) + + it('should render empty string when value is undefined', () => { + render(<SummaryText onChange={onChange} />) + expect(screen.getByTestId('textarea')).toHaveValue('') + }) + + it('should call onChange when text changes', () => { + render(<SummaryText value="" onChange={onChange} />) + fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'new summary' } }) + expect(onChange).toHaveBeenCalledWith('new summary') + }) + + it('should disable textarea when disabled', () => { + render(<SummaryText value="text" disabled />) + expect(screen.getByTestId('textarea')).toBeDisabled() + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx new file mode 100644 index 0000000000..c6176eeefa --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx @@ -0,0 +1,233 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SummaryLabel from '../summary-label' +import SummaryStatus from '../summary-status' +import SummaryText from '../summary-text' + +describe('SummaryLabel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies the component renders with its heading and summary text + describe('Rendering', () => { + it('should render without crashing', () => { + render(<SummaryLabel />) + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should render the summary heading with divider', () => { + render(<SummaryLabel summary="Test summary" />) + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should render summary text when provided', () => { + render(<SummaryLabel summary="My summary content" />) + expect(screen.getByText('My summary content')).toBeInTheDocument() + }) + }) + + // Props: tests different prop combinations + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render(<SummaryLabel summary="test" className="custom-class" />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + expect(wrapper).toHaveClass('space-y-1') + }) + + it('should render without className prop', () => { + const { container } = render(<SummaryLabel summary="test" />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('space-y-1') + }) + }) + + // Edge Cases: tests undefined/empty/special values + describe('Edge Cases', () => { + it('should handle undefined summary', () => { + render(<SummaryLabel />) + // Heading should still render + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should handle empty string summary', () => { + render(<SummaryLabel summary="" />) + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should handle summary with special characters', () => { + const summary = '<b>bold</b> & "quotes"' + render(<SummaryLabel summary={summary} />) + expect(screen.getByText(summary)).toBeInTheDocument() + }) + + it('should handle very long summary', () => { + const longSummary = 'A'.repeat(1000) + render(<SummaryLabel summary={longSummary} />) + expect(screen.getByText(longSummary)).toBeInTheDocument() + }) + + it('should handle both className and summary as undefined', () => { + const { container } = render(<SummaryLabel />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('space-y-1') + }) + }) +}) + +describe('SummaryStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies badge rendering based on status + describe('Rendering', () => { + it('should render without crashing', () => { + render(<SummaryStatus status="COMPLETED" />) + // Should not crash even for non-SUMMARIZING status + }) + + it('should render badge when status is SUMMARIZING', () => { + render(<SummaryStatus status="SUMMARIZING" />) + expect(screen.getByText(/list\.summary\.generating/)).toBeInTheDocument() + }) + + it('should not render badge when status is not SUMMARIZING', () => { + render(<SummaryStatus status="COMPLETED" />) + expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument() + }) + }) + + // Props: tests tooltip content based on status + describe('Props', () => { + it('should show tooltip with generating summary message when SUMMARIZING', () => { + render(<SummaryStatus status="SUMMARIZING" />) + // The tooltip popupContent is set to the i18n key for generatingSummary + expect(screen.getByText(/list\.summary\.generating/)).toBeInTheDocument() + }) + }) + + // Edge Cases: tests different status values + describe('Edge Cases', () => { + it('should not render badge for empty string status', () => { + render(<SummaryStatus status="" />) + expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument() + }) + + it('should not render badge for lowercase summarizing', () => { + render(<SummaryStatus status="summarizing" />) + expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument() + }) + + it('should not render badge for DONE status', () => { + render(<SummaryStatus status="DONE" />) + expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument() + }) + + it('should not render badge for FAILED status', () => { + render(<SummaryStatus status="FAILED" />) + expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument() + }) + }) +}) + +describe('SummaryText', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies the label and textarea render correctly + describe('Rendering', () => { + it('should render without crashing', () => { + render(<SummaryText />) + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should render the summary label', () => { + render(<SummaryText value="hello" />) + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should render textarea with placeholder', () => { + render(<SummaryText />) + const textarea = screen.getByRole('textbox') + expect(textarea).toBeInTheDocument() + expect(textarea).toHaveAttribute('placeholder', expect.stringContaining('segment.summaryPlaceholder')) + }) + }) + + // Props: tests value, onChange, and disabled behavior + describe('Props', () => { + it('should display the value prop in textarea', () => { + render(<SummaryText value="My summary" />) + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue('My summary') + }) + + it('should display empty string when value is undefined', () => { + render(<SummaryText />) + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue('') + }) + + it('should call onChange when textarea value changes', () => { + const onChange = vi.fn() + render(<SummaryText value="" onChange={onChange} />) + const textarea = screen.getByRole('textbox') + + fireEvent.change(textarea, { target: { value: 'new value' } }) + + expect(onChange).toHaveBeenCalledWith('new value') + }) + + it('should disable textarea when disabled is true', () => { + render(<SummaryText value="test" disabled={true} />) + const textarea = screen.getByRole('textbox') + expect(textarea).toBeDisabled() + }) + + it('should enable textarea when disabled is false', () => { + render(<SummaryText value="test" disabled={false} />) + const textarea = screen.getByRole('textbox') + expect(textarea).not.toBeDisabled() + }) + + it('should enable textarea when disabled is undefined', () => { + render(<SummaryText value="test" />) + const textarea = screen.getByRole('textbox') + expect(textarea).not.toBeDisabled() + }) + }) + + // Edge Cases: tests missing onChange and edge value scenarios + describe('Edge Cases', () => { + it('should not throw when onChange is undefined and user types', () => { + render(<SummaryText value="" />) + const textarea = screen.getByRole('textbox') + expect(() => { + fireEvent.change(textarea, { target: { value: 'typed' } }) + }).not.toThrow() + }) + + it('should handle empty string value', () => { + render(<SummaryText value="" />) + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue('') + }) + + it('should handle very long value', () => { + const longValue = 'B'.repeat(5000) + render(<SummaryText value={longValue} />) + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue(longValue) + }) + + it('should handle value with special characters', () => { + const special = '<script>alert("x")</script>' + render(<SummaryText value={special} />) + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue(special) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/tag.spec.tsx similarity index 84% rename from web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/tag.spec.tsx index 8456652126..17966ad3b2 100644 --- a/web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/tag.spec.tsx @@ -1,39 +1,30 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Tag from './tag' +import Tag from '../tag' describe('Tag', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<Tag text="test" />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the hash symbol', () => { - // Arrange & Act render(<Tag text="test" />) - // Assert expect(screen.getByText('#')).toBeInTheDocument() }) it('should render the text content', () => { - // Arrange & Act render(<Tag text="keyword" />) - // Assert expect(screen.getByText('keyword')).toBeInTheDocument() }) it('should render with correct base styling classes', () => { - // Arrange & Act const { container } = render(<Tag text="test" />) - // Assert const tagElement = container.firstChild as HTMLElement expect(tagElement).toHaveClass('inline-flex') expect(tagElement).toHaveClass('items-center') @@ -41,87 +32,67 @@ describe('Tag', () => { }) }) - // Props tests describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render(<Tag text="test" className="custom-class" />) - // Assert const tagElement = container.firstChild as HTMLElement expect(tagElement).toHaveClass('custom-class') }) it('should render different text values', () => { - // Arrange & Act const { rerender } = render(<Tag text="first" />) expect(screen.getByText('first')).toBeInTheDocument() - // Act rerender(<Tag text="second" />) - // Assert expect(screen.getByText('second')).toBeInTheDocument() }) }) - // Structure tests describe('Structure', () => { it('should render hash with quaternary text color', () => { - // Arrange & Act const { container } = render(<Tag text="test" />) - // Assert const hashSpan = container.querySelector('.text-text-quaternary') expect(hashSpan).toBeInTheDocument() expect(hashSpan).toHaveTextContent('#') }) it('should render text with tertiary text color', () => { - // Arrange & Act const { container } = render(<Tag text="test" />) - // Assert const textSpan = container.querySelector('.text-text-tertiary') expect(textSpan).toBeInTheDocument() expect(textSpan).toHaveTextContent('test') }) it('should have truncate class for text overflow', () => { - // Arrange & Act const { container } = render(<Tag text="very-long-text-that-might-overflow" />) - // Assert const textSpan = container.querySelector('.truncate') expect(textSpan).toBeInTheDocument() }) it('should have max-width constraint on text', () => { - // Arrange & Act const { container } = render(<Tag text="test" />) - // Assert const textSpan = container.querySelector('.max-w-12') expect(textSpan).toBeInTheDocument() }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently with same props', () => { - // Arrange & Act const { container: container1 } = render(<Tag text="test" />) const { container: container2 } = render(<Tag text="test" />) - // Assert expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent) }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty text', () => { - // Arrange & Act render(<Tag text="" />) // Assert - should still render the hash symbol @@ -129,21 +100,16 @@ describe('Tag', () => { }) it('should handle special characters in text', () => { - // Arrange & Act render(<Tag text="test-tag_1" />) - // Assert expect(screen.getByText('test-tag_1')).toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<Tag text="test" />) - // Act rerender(<Tag text="test" />) - // Assert expect(screen.getByText('#')).toBeInTheDocument() expect(screen.getByText('test')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx new file mode 100644 index 0000000000..dfcb02215c --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx @@ -0,0 +1,106 @@ +import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode } from '@/models/datasets' +import DrawerGroup from '../drawer-group' + +vi.mock('../../common/full-screen-drawer', () => ({ + default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => ( + isOpen ? <div data-testid="full-screen-drawer">{children}</div> : null + ), +})) + +vi.mock('../../segment-detail', () => ({ + default: () => <div data-testid="segment-detail" />, +})) + +vi.mock('../../child-segment-detail', () => ({ + default: () => <div data-testid="child-segment-detail" />, +})) + +vi.mock('../../new-child-segment', () => ({ + default: () => <div data-testid="new-child-segment" />, +})) + +vi.mock('@/app/components/datasets/documents/detail/new-segment', () => ({ + default: () => <div data-testid="new-segment" />, +})) + +describe('DrawerGroup', () => { + const defaultProps = { + currSegment: { segInfo: undefined, showModal: false, isEditMode: false }, + onCloseSegmentDetail: vi.fn(), + onUpdateSegment: vi.fn(), + isRegenerationModalOpen: false, + setIsRegenerationModalOpen: vi.fn(), + showNewSegmentModal: false, + onCloseNewSegmentModal: vi.fn(), + onSaveNewSegment: vi.fn(), + viewNewlyAddedChunk: vi.fn(), + currChildChunk: { childChunkInfo: undefined, showModal: false }, + currChunkId: 'chunk-1', + onCloseChildSegmentDetail: vi.fn(), + onUpdateChildChunk: vi.fn(), + showNewChildSegmentModal: false, + onCloseNewChildChunkModal: vi.fn(), + onSaveNewChildChunk: vi.fn(), + viewNewlyAddedChildChunk: vi.fn(), + fullScreen: false, + docForm: ChunkingMode.text, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render nothing when all modals are closed', () => { + const { container } = render(<DrawerGroup {...defaultProps} />) + expect(container.querySelector('[data-testid="full-screen-drawer"]')).toBeNull() + }) + + it('should render segment detail when segment modal is open', () => { + render( + <DrawerGroup + {...defaultProps} + currSegment={{ segInfo: { id: 'seg-1' } as SegmentDetailModel, showModal: true, isEditMode: true }} + />, + ) + expect(screen.getByTestId('segment-detail')).toBeInTheDocument() + }) + + it('should render new segment modal when showNewSegmentModal is true', () => { + render( + <DrawerGroup {...defaultProps} showNewSegmentModal={true} />, + ) + expect(screen.getByTestId('new-segment')).toBeInTheDocument() + }) + + it('should render child segment detail when child chunk modal is open', () => { + render( + <DrawerGroup + {...defaultProps} + currChildChunk={{ childChunkInfo: { id: 'child-1' } as ChildChunkDetail, showModal: true }} + />, + ) + expect(screen.getByTestId('child-segment-detail')).toBeInTheDocument() + }) + + it('should render new child segment modal when showNewChildSegmentModal is true', () => { + render( + <DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />, + ) + expect(screen.getByTestId('new-child-segment')).toBeInTheDocument() + }) + + it('should render multiple drawers simultaneously', () => { + render( + <DrawerGroup + {...defaultProps} + currSegment={{ segInfo: { id: 'seg-1' } as SegmentDetailModel, showModal: true }} + showNewChildSegmentModal={true} + />, + ) + expect(screen.getByTestId('segment-detail')).toBeInTheDocument() + expect(screen.getByTestId('new-child-segment')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx new file mode 100644 index 0000000000..0cc6c28d52 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import MenuBar from '../menu-bar' + +vi.mock('../../display-toggle', () => ({ + default: ({ isCollapsed, toggleCollapsed }: { isCollapsed: boolean, toggleCollapsed: () => void }) => ( + <button data-testid="display-toggle" onClick={toggleCollapsed}> + {isCollapsed ? 'collapsed' : 'expanded'} + </button> + ), +})) + +vi.mock('../../status-item', () => ({ + default: ({ item }: { item: { name: string } }) => <div data-testid="status-item">{item.name}</div>, +})) + +describe('MenuBar', () => { + const defaultProps = { + isAllSelected: false, + isSomeSelected: false, + onSelectedAll: vi.fn(), + isLoading: false, + totalText: '10 Chunks', + statusList: [ + { value: 'all', name: 'All' }, + { value: 0, name: 'Enabled' }, + { value: 1, name: 'Disabled' }, + ], + selectDefaultValue: 'all' as const, + onChangeStatus: vi.fn(), + inputValue: '', + onInputChange: vi.fn(), + isCollapsed: false, + toggleCollapsed: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render total text', () => { + render(<MenuBar {...defaultProps} />) + expect(screen.getByText('10 Chunks')).toBeInTheDocument() + }) + + it('should render checkbox', () => { + const { container } = render(<MenuBar {...defaultProps} />) + const checkbox = container.querySelector('[class*="shrink-0"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should call onInputChange when input changes', () => { + render(<MenuBar {...defaultProps} />) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'test search' } }) + expect(defaultProps.onInputChange).toHaveBeenCalledWith('test search') + }) + + it('should render display toggle', () => { + render(<MenuBar {...defaultProps} />) + expect(screen.getByTestId('display-toggle')).toBeInTheDocument() + }) + + it('should call toggleCollapsed when display toggle clicked', () => { + render(<MenuBar {...defaultProps} />) + fireEvent.click(screen.getByTestId('display-toggle')) + expect(defaultProps.toggleCollapsed).toHaveBeenCalled() + }) + + it('should call onInputChange with empty string when input is cleared', () => { + render(<MenuBar {...defaultProps} inputValue="some text" />) + const clearButton = screen.getByTestId('input-clear') + fireEvent.click(clearButton) + expect(defaultProps.onInputChange).toHaveBeenCalledWith('') + }) + + it('should render select with status items via renderOption', () => { + render(<MenuBar {...defaultProps} />) + expect(screen.getByText('All')).toBeInTheDocument() + }) + + it('should call renderOption for each item when dropdown is opened', async () => { + render(<MenuBar {...defaultProps} />) + + const selectButton = screen.getByRole('button', { name: /All/i }) + fireEvent.click(selectButton) + + // After opening, renderOption is called for each item, rendering the mocked StatusItem + const statusItems = await screen.findAllByTestId('status-item') + expect(statusItems.length).toBe(3) + expect(statusItems[0]).toHaveTextContent('All') + expect(statusItems[1]).toHaveTextContent('Enabled') + expect(statusItems[2]).toHaveTextContent('Disabled') + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx new file mode 100644 index 0000000000..eeeeca333d --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx @@ -0,0 +1,103 @@ +import type { SegmentDetailModel } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FullDocModeContent, GeneralModeContent } from '../segment-list-content' + +vi.mock('../../child-segment-list', () => ({ + default: ({ parentChunkId }: { parentChunkId: string }) => ( + <div data-testid="child-segment-list">{parentChunkId}</div> + ), +})) + +vi.mock('../../segment-card', () => ({ + default: ({ detail, onClick }: { detail: { id: string }, onClick?: () => void }) => ( + <div data-testid="segment-card" onClick={onClick}>{detail?.id}</div> + ), +})) + +vi.mock('../../segment-list', () => { + const SegmentList = vi.fn(({ items }: { items: { id: string }[] }) => ( + <div data-testid="segment-list"> + {items?.length ?? 0} + {' '} + items + </div> + )) + return { default: SegmentList } +}) + +describe('FullDocModeContent', () => { + const defaultProps = { + segments: [{ id: 'seg-1', position: 1, content: 'test', word_count: 10 }] as SegmentDetailModel[], + childSegments: [], + isLoadingSegmentList: false, + isLoadingChildSegmentList: false, + currSegmentId: undefined, + onClickCard: vi.fn(), + onDeleteChildChunk: vi.fn(), + handleInputChange: vi.fn(), + handleAddNewChildChunk: vi.fn(), + onClickSlice: vi.fn(), + archived: false, + childChunkTotal: 0, + inputValue: '', + onClearFilter: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render segment card with first segment', () => { + render(<FullDocModeContent {...defaultProps} />) + expect(screen.getByTestId('segment-card')).toHaveTextContent('seg-1') + }) + + it('should render child segment list', () => { + render(<FullDocModeContent {...defaultProps} />) + expect(screen.getByTestId('child-segment-list')).toHaveTextContent('seg-1') + }) + + it('should apply overflow-y-hidden when loading', () => { + const { container } = render( + <FullDocModeContent {...defaultProps} isLoadingSegmentList={true} />, + ) + expect(container.firstChild).toHaveClass('overflow-y-hidden') + }) + + it('should apply overflow-y-auto when not loading', () => { + const { container } = render(<FullDocModeContent {...defaultProps} />) + expect(container.firstChild).toHaveClass('overflow-y-auto') + }) + + it('should call onClickCard with first segment when segment card is clicked', () => { + const onClickCard = vi.fn() + render(<FullDocModeContent {...defaultProps} onClickCard={onClickCard} />) + fireEvent.click(screen.getByTestId('segment-card')) + expect(onClickCard).toHaveBeenCalledWith(defaultProps.segments[0]) + }) +}) + +describe('GeneralModeContent', () => { + const defaultProps = { + segmentListRef: { current: null }, + embeddingAvailable: true, + isLoadingSegmentList: false, + segments: [{ id: 'seg-1' }, { id: 'seg-2' }] as SegmentDetailModel[], + selectedSegmentIds: [], + onSelected: vi.fn(), + onChangeSwitch: vi.fn(), + onDelete: vi.fn(), + onClickCard: vi.fn(), + archived: false, + onDeleteChildChunk: vi.fn(), + handleAddNewChildChunk: vi.fn(), + onClickSlice: vi.fn(), + onClearFilter: vi.fn(), + } + + it('should render segment list with items', () => { + render(<GeneralModeContent {...defaultProps} />) + expect(screen.getByTestId('segment-list')).toHaveTextContent('2 items') + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts similarity index 76% rename from web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.spec.ts rename to web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts index 66a2f9e541..83918a3f30 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts @@ -3,7 +3,7 @@ import type { ChildChunkDetail, ChildSegmentsResponse, ChunkingMode, ParentMode, import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import * as React from 'react' -import { useChildSegmentData } from './use-child-segment-data' +import { useChildSegmentData } from '../use-child-segment-data' // Type for mutation callbacks type MutationResponse = { data: ChildChunkDetail } @@ -13,9 +13,7 @@ type MutationCallbacks = { } type _ErrorCallback = { onSuccess?: () => void, onError: () => void } -// ============================================================================ // Hoisted Mocks -// ============================================================================ const { mockParentMode, @@ -41,21 +39,6 @@ const { mockInvalidChildSegmentList: vi.fn(), })) -// Mock dependencies -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - if (key === 'actionMsg.modifiedSuccessfully') - return 'Modified successfully' - if (key === 'actionMsg.modifiedUnsuccessfully') - return 'Modified unsuccessfully' - if (key === 'segment.contentEmpty') - return 'Content cannot be empty' - return key - }, - }), -})) - vi.mock('@tanstack/react-query', async () => { const actual = await vi.importActual('@tanstack/react-query') return { @@ -64,7 +47,7 @@ vi.mock('@tanstack/react-query', async () => { } }) -vi.mock('../../context', () => ({ +vi.mock('../../../context', () => ({ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { const value: DocumentContextValue = { datasetId: mockDatasetId.current, @@ -98,10 +81,6 @@ vi.mock('@/service/use-base', () => ({ useInvalid: () => mockInvalidChildSegmentList, })) -// ============================================================================ -// Test Utilities -// ============================================================================ - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -167,9 +146,7 @@ const defaultOptions = { updateSegmentInCache: vi.fn(), } -// ============================================================================ // Tests -// ============================================================================ describe('useChildSegmentData', () => { beforeEach(() => { @@ -226,7 +203,7 @@ describe('useChildSegmentData', () => { }) expect(mockDeleteChildSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function)) }) @@ -261,7 +238,7 @@ describe('useChildSegmentData', () => { await result.current.onDeleteChildChunk('seg-1', 'child-1') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.actionMsg.modifiedUnsuccessfully' }) }) }) @@ -275,7 +252,7 @@ describe('useChildSegmentData', () => { await result.current.handleUpdateChildChunk('seg-1', 'child-1', ' ') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Content cannot be empty' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.contentEmpty' }) expect(mockUpdateChildSegment).not.toHaveBeenCalled() }) @@ -311,7 +288,7 @@ describe('useChildSegmentData', () => { }) expect(mockUpdateChildSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) expect(onCloseChildSegmentDetail).toHaveBeenCalled() expect(updateSegmentInCache).toHaveBeenCalled() expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled() @@ -564,5 +541,151 @@ describe('useChildSegmentData', () => { expect(mockQueryClient.setQueryData).toHaveBeenCalled() }) + + it('should handle updateChildSegmentInCache when old data is undefined', async () => { + mockParentMode.current = 'full-doc' + const onCloseChildSegmentDetail = vi.fn() + + // Capture the setQueryData callback to verify null-safety + mockQueryClient.setQueryData.mockImplementation((_key: unknown, updater: (old: unknown) => unknown) => { + if (typeof updater === 'function') { + // Invoke with undefined to cover the !old branch + const resultWithUndefined = updater(undefined) + expect(resultWithUndefined).toBeUndefined() + // Also test with real data + const resultWithData = updater({ + data: [ + createMockChildChunk({ id: 'child-1', content: 'old content' }), + createMockChildChunk({ id: 'child-2', content: 'other' }), + ], + total: 2, + total_pages: 1, + }) as ChildSegmentsResponse + expect(resultWithData.data[0].content).toBe('new content') + expect(resultWithData.data[1].content).toBe('other') + } + }) + + mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => { + onSuccess({ + data: createMockChildChunk({ + id: 'child-1', + content: 'new content', + type: 'customized', + word_count: 50, + updated_at: 1700000001, + }), + }) + onSettled() + }) + + const { result } = renderHook(() => useChildSegmentData({ + ...defaultOptions, + onCloseChildSegmentDetail, + }), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content') + }) + + expect(mockQueryClient.setQueryData).toHaveBeenCalled() + }) + }) + + describe('Scroll to bottom effect', () => { + it('should scroll to bottom when childSegments change and needScrollToBottom is true', () => { + // Start with empty data + mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 } + + const { result, rerender } = renderHook(() => useChildSegmentData(defaultOptions), { + wrapper: createWrapper(), + }) + + // Set up the ref to a mock DOM element + const mockScrollTo = vi.fn() + Object.defineProperty(result.current.childSegmentListRef, 'current', { + value: { scrollTo: mockScrollTo, scrollHeight: 500 }, + writable: true, + }) + result.current.needScrollToBottom.current = true + + // Change mock data to trigger the useEffect + mockChildSegmentListData.current = { + data: [createMockChildChunk({ id: 'new-child' })], + total: 1, + total_pages: 1, + page: 1, + limit: 20, + } + rerender() + + expect(mockScrollTo).toHaveBeenCalledWith({ top: 500, behavior: 'smooth' }) + expect(result.current.needScrollToBottom.current).toBe(false) + }) + + it('should not scroll when needScrollToBottom is false', () => { + mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 } + + const { result, rerender } = renderHook(() => useChildSegmentData(defaultOptions), { + wrapper: createWrapper(), + }) + + const mockScrollTo = vi.fn() + Object.defineProperty(result.current.childSegmentListRef, 'current', { + value: { scrollTo: mockScrollTo, scrollHeight: 500 }, + writable: true, + }) + // needScrollToBottom remains false + + mockChildSegmentListData.current = { + data: [createMockChildChunk()], + total: 1, + total_pages: 1, + page: 1, + limit: 20, + } + rerender() + + expect(mockScrollTo).not.toHaveBeenCalled() + }) + + it('should not scroll when childSegmentListRef is null', () => { + mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 } + + const { result, rerender } = renderHook(() => useChildSegmentData(defaultOptions), { + wrapper: createWrapper(), + }) + + // ref.current stays null, needScrollToBottom is true + result.current.needScrollToBottom.current = true + + mockChildSegmentListData.current = { + data: [createMockChildChunk()], + total: 1, + total_pages: 1, + page: 1, + limit: 20, + } + rerender() + + // needScrollToBottom stays true since scroll didn't happen + expect(result.current.needScrollToBottom.current).toBe(true) + }) + }) + + describe('Query params edge cases', () => { + it('should handle currentPage of 0 by defaulting to page 1', () => { + const { result } = renderHook(() => useChildSegmentData({ + ...defaultOptions, + currentPage: 0, + }), { + wrapper: createWrapper(), + }) + + // Should still work with page defaulted to 1 + expect(result.current.childSegments).toEqual([]) + }) }) }) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts new file mode 100644 index 0000000000..57e7ae5d5e --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts @@ -0,0 +1,146 @@ +import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useModalState } from '../use-modal-state' + +describe('useModalState', () => { + const onNewSegmentModalChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + const renderUseModalState = () => + renderHook(() => useModalState({ onNewSegmentModalChange })) + + it('should initialize with all modals closed', () => { + const { result } = renderUseModalState() + + expect(result.current.currSegment.showModal).toBe(false) + expect(result.current.currChildChunk.showModal).toBe(false) + expect(result.current.showNewChildSegmentModal).toBe(false) + expect(result.current.isRegenerationModalOpen).toBe(false) + expect(result.current.fullScreen).toBe(false) + expect(result.current.isCollapsed).toBe(true) + }) + + it('should open segment detail on card click', () => { + const { result } = renderUseModalState() + const detail = { id: 'seg-1', content: 'test' } as unknown as SegmentDetailModel + + act(() => { + result.current.onClickCard(detail, true) + }) + + expect(result.current.currSegment.showModal).toBe(true) + expect(result.current.currSegment.segInfo).toBe(detail) + expect(result.current.currSegment.isEditMode).toBe(true) + }) + + it('should close segment detail and reset fullscreen', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.onClickCard({ id: 'seg-1' } as unknown as SegmentDetailModel) + }) + act(() => { + result.current.setFullScreen(true) + }) + act(() => { + result.current.onCloseSegmentDetail() + }) + + expect(result.current.currSegment.showModal).toBe(false) + expect(result.current.fullScreen).toBe(false) + }) + + it('should open child segment detail on slice click', () => { + const { result } = renderUseModalState() + const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail + + act(() => { + result.current.onClickSlice(childDetail) + }) + + expect(result.current.currChildChunk.showModal).toBe(true) + expect(result.current.currChildChunk.childChunkInfo).toBe(childDetail) + expect(result.current.currChunkId).toBe('seg-1') + }) + + it('should close child segment detail', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.onClickSlice({ id: 'c1', segment_id: 's1' } as unknown as ChildChunkDetail) + }) + act(() => { + result.current.onCloseChildSegmentDetail() + }) + + expect(result.current.currChildChunk.showModal).toBe(false) + }) + + it('should handle new child chunk modal', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.handleAddNewChildChunk('parent-chunk-1') + }) + + expect(result.current.showNewChildSegmentModal).toBe(true) + expect(result.current.currChunkId).toBe('parent-chunk-1') + + act(() => { + result.current.onCloseNewChildChunkModal() + }) + + expect(result.current.showNewChildSegmentModal).toBe(false) + }) + + it('should close new segment modal and notify parent', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.onCloseNewSegmentModal() + }) + + expect(onNewSegmentModalChange).toHaveBeenCalledWith(false) + }) + + it('should toggle full screen', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.toggleFullScreen() + }) + expect(result.current.fullScreen).toBe(true) + + act(() => { + result.current.toggleFullScreen() + }) + expect(result.current.fullScreen).toBe(false) + }) + + it('should toggle collapsed', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.toggleCollapsed() + }) + expect(result.current.isCollapsed).toBe(false) + + act(() => { + result.current.toggleCollapsed() + }) + expect(result.current.isCollapsed).toBe(true) + }) + + it('should set regeneration modal state', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.setIsRegenerationModalOpen(true) + }) + expect(result.current.isRegenerationModalOpen).toBe(true) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-search-filter.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-search-filter.spec.ts new file mode 100644 index 0000000000..31b644b73b --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-search-filter.spec.ts @@ -0,0 +1,124 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSearchFilter } from '../use-search-filter' + +describe('useSearchFilter', () => { + const onPageChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should initialize with default values', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + expect(result.current.inputValue).toBe('') + expect(result.current.searchValue).toBe('') + expect(result.current.selectedStatus).toBe('all') + expect(result.current.selectDefaultValue).toBe('all') + }) + + it('should provide status list with three items', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + expect(result.current.statusList).toHaveLength(3) + }) + + it('should update input value immediately on handleInputChange', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.handleInputChange('test query') + }) + + expect(result.current.inputValue).toBe('test query') + }) + + it('should update search value after debounce', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.handleInputChange('debounced') + }) + + // Before debounce + expect(result.current.searchValue).toBe('') + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(result.current.searchValue).toBe('debounced') + expect(onPageChange).toHaveBeenCalledWith(1) + }) + + it('should change status and reset page', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.onChangeStatus({ value: 1, name: 'Enabled' }) + }) + + expect(result.current.selectedStatus).toBe(true) + expect(result.current.selectDefaultValue).toBe(1) + expect(onPageChange).toHaveBeenCalledWith(1) + }) + + it('should set status to false when value is 0', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.onChangeStatus({ value: 0, name: 'Disabled' }) + }) + + expect(result.current.selectedStatus).toBe(false) + expect(result.current.selectDefaultValue).toBe(0) + }) + + it('should set status to "all" when value is "all"', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.onChangeStatus({ value: 1, name: 'Enabled' }) + }) + act(() => { + result.current.onChangeStatus({ value: 'all', name: 'All' }) + }) + + expect(result.current.selectedStatus).toBe('all') + }) + + it('should clear all filters on onClearFilter', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.handleInputChange('test') + vi.advanceTimersByTime(500) + }) + act(() => { + result.current.onChangeStatus({ value: 1, name: 'Enabled' }) + }) + + act(() => { + result.current.onClearFilter() + }) + + expect(result.current.inputValue).toBe('') + expect(result.current.searchValue).toBe('') + expect(result.current.selectedStatus).toBe('all') + }) + + it('should reset page on resetPage', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.resetPage() + }) + + expect(onPageChange).toHaveBeenCalledWith(1) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts similarity index 92% rename from web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.spec.ts rename to web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts index e90994661d..aef2053298 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts @@ -5,8 +5,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import * as React from 'react' import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets' -import { ProcessStatus } from '../../segment-add' -import { useSegmentListData } from './use-segment-list-data' +import { ProcessStatus } from '../../../segment-add' +import { useSegmentListData } from '../use-segment-list-data' // Type for mutation callbacks type SegmentMutationResponse = { data: SegmentDetailModel } @@ -28,9 +28,7 @@ const createMockFileEntity = (overrides: Partial<FileEntity> = {}): FileEntity = ...overrides, }) -// ============================================================================ // Hoisted Mocks -// ============================================================================ const { mockDocForm, @@ -70,33 +68,6 @@ const { mockPathname: { current: '/datasets/test/documents/test' }, })) -// Mock dependencies -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { count?: number, ns?: string }) => { - if (key === 'actionMsg.modifiedSuccessfully') - return 'Modified successfully' - if (key === 'actionMsg.modifiedUnsuccessfully') - return 'Modified unsuccessfully' - if (key === 'segment.contentEmpty') - return 'Content cannot be empty' - if (key === 'segment.questionEmpty') - return 'Question cannot be empty' - if (key === 'segment.answerEmpty') - return 'Answer cannot be empty' - if (key === 'segment.allFilesUploaded') - return 'All files must be uploaded' - if (key === 'segment.chunks') - return options?.count === 1 ? 'chunk' : 'chunks' - if (key === 'segment.parentChunks') - return options?.count === 1 ? 'parent chunk' : 'parent chunks' - if (key === 'segment.searchResults') - return 'search results' - return `${options?.ns || ''}.${key}` - }, - }), -})) - vi.mock('next/navigation', () => ({ usePathname: () => mockPathname.current, })) @@ -109,7 +80,7 @@ vi.mock('@tanstack/react-query', async () => { } }) -vi.mock('../../context', () => ({ +vi.mock('../../../context', () => ({ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { const value: DocumentContextValue = { datasetId: mockDatasetId.current, @@ -157,10 +128,6 @@ vi.mock('@/service/use-base', () => ({ }, })) -// ============================================================================ -// Test Utilities -// ============================================================================ - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -213,9 +180,7 @@ const defaultOptions = { clearSelection: vi.fn(), } -// ============================================================================ // Tests -// ============================================================================ describe('useSegmentListData', () => { beforeEach(() => { @@ -269,7 +234,7 @@ describe('useSegmentListData', () => { }) expect(result.current.totalText).toContain('10') - expect(result.current.totalText).toContain('chunks') + expect(result.current.totalText).toContain('datasetDocuments.segment.chunks') }) it('should show search results when searching', () => { @@ -283,7 +248,7 @@ describe('useSegmentListData', () => { }) expect(result.current.totalText).toContain('5') - expect(result.current.totalText).toContain('search results') + expect(result.current.totalText).toContain('datasetDocuments.segment.searchResults') }) it('should show search results when status is filtered', () => { @@ -296,7 +261,7 @@ describe('useSegmentListData', () => { wrapper: createWrapper(), }) - expect(result.current.totalText).toContain('search results') + expect(result.current.totalText).toContain('datasetDocuments.segment.searchResults') }) it('should show parent chunks in parentChild paragraph mode', () => { @@ -308,7 +273,7 @@ describe('useSegmentListData', () => { wrapper: createWrapper(), }) - expect(result.current.totalText).toContain('parent chunk') + expect(result.current.totalText).toContain('datasetDocuments.segment.parentChunks') }) it('should show "--" when total is undefined', () => { @@ -398,7 +363,7 @@ describe('useSegmentListData', () => { }) expect(mockEnableSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) }) it('should call disableSegment when enable is false', async () => { @@ -452,7 +417,7 @@ describe('useSegmentListData', () => { await result.current.onChangeSwitch(true, 'seg-1') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.actionMsg.modifiedUnsuccessfully' }) }) }) @@ -475,7 +440,7 @@ describe('useSegmentListData', () => { }) expect(mockDeleteSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) }) it('should clear selection when deleting batch (no segId)', async () => { @@ -513,7 +478,7 @@ describe('useSegmentListData', () => { await result.current.onDelete('seg-1') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.actionMsg.modifiedUnsuccessfully' }) }) }) @@ -527,7 +492,7 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', ' ', '', [], []) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Content cannot be empty' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.contentEmpty' }) expect(mockUpdateSegment).not.toHaveBeenCalled() }) @@ -542,7 +507,7 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', '', 'answer', [], []) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Question cannot be empty' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.questionEmpty' }) }) it('should validate empty answer in QA mode', async () => { @@ -556,7 +521,7 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', 'question', ' ', [], []) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Answer cannot be empty' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.answerEmpty' }) }) it('should validate attachments are uploaded', async () => { @@ -570,7 +535,7 @@ describe('useSegmentListData', () => { ]) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'All files must be uploaded' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.allFilesUploaded' }) }) it('should call updateSegment with correct params', async () => { @@ -592,7 +557,7 @@ describe('useSegmentListData', () => { }) expect(mockUpdateSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) expect(onCloseSegmentDetail).toHaveBeenCalled() expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment') expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment-success') diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts new file mode 100644 index 0000000000..382baf69a8 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts @@ -0,0 +1,159 @@ +import type { SegmentDetailModel } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSegmentSelection } from '../use-segment-selection' + +describe('useSegmentSelection', () => { + const segments = [ + { id: 'seg-1', content: 'A' }, + { id: 'seg-2', content: 'B' }, + { id: 'seg-3', content: 'C' }, + ] as unknown as SegmentDetailModel[] + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should initialize with empty selection', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + expect(result.current.selectedSegmentIds).toEqual([]) + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isSomeSelected).toBe(false) + }) + + it('should select a segment', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + + expect(result.current.selectedSegmentIds).toEqual(['seg-1']) + expect(result.current.isSomeSelected).toBe(true) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should deselect a selected segment', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + act(() => { + result.current.onSelected('seg-1') + }) + + expect(result.current.selectedSegmentIds).toEqual([]) + }) + + it('should select all segments', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelectedAll() + }) + + expect(result.current.selectedSegmentIds).toEqual(['seg-1', 'seg-2', 'seg-3']) + expect(result.current.isAllSelected).toBe(true) + }) + + it('should deselect all when all are selected', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelectedAll() + }) + act(() => { + result.current.onSelectedAll() + }) + + expect(result.current.selectedSegmentIds).toEqual([]) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should cancel batch operation', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + result.current.onSelected('seg-2') + }) + act(() => { + result.current.onCancelBatchOperation() + }) + + expect(result.current.selectedSegmentIds).toEqual([]) + }) + + it('should clear selection', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + act(() => { + result.current.clearSelection() + }) + + expect(result.current.selectedSegmentIds).toEqual([]) + }) + + it('should handle empty segments array', () => { + const { result } = renderHook(() => useSegmentSelection([])) + + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isSomeSelected).toBe(false) + }) + + it('should allow multiple selections', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + act(() => { + result.current.onSelected('seg-2') + }) + + expect(result.current.selectedSegmentIds).toEqual(['seg-1', 'seg-2']) + expect(result.current.isSomeSelected).toBe(true) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should preserve selection of segments not in current list', () => { + const { result, rerender } = renderHook( + ({ segs }) => useSegmentSelection(segs), + { initialProps: { segs: segments } }, + ) + + act(() => { + result.current.onSelected('seg-1') + }) + + // Rerender with different segment list (simulating page change) + const newSegments = [ + { id: 'seg-4', content: 'D' }, + { id: 'seg-5', content: 'E' }, + ] as unknown as SegmentDetailModel[] + + rerender({ segs: newSegments }) + + // Previously selected segment should still be in selectedSegmentIds + expect(result.current.selectedSegmentIds).toContain('seg-1') + }) + + it('should select remaining unselected segments when onSelectedAll is called with partial selection', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + act(() => { + result.current.onSelectedAll() + }) + + expect(result.current.selectedSegmentIds).toEqual(expect.arrayContaining(['seg-1', 'seg-2', 'seg-3'])) + expect(result.current.isAllSelected).toBe(true) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/chunk-content.spec.tsx similarity index 92% rename from web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx rename to web/app/components/datasets/documents/detail/completed/segment-card/__tests__/chunk-content.spec.tsx index 570d93d390..3b6492939c 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/chunk-content.spec.tsx @@ -4,7 +4,7 @@ import { noop } from 'es-toolkit/function' import { createContext, useContextSelector } from 'use-context-selector' import { describe, expect, it, vi } from 'vitest' -import ChunkContent from './chunk-content' +import ChunkContent from '../chunk-content' // Create mock context matching the actual SegmentListContextValue type SegmentListContextValue = { @@ -24,7 +24,7 @@ const MockSegmentListContext = createContext<SegmentListContextValue>({ }) // Mock the context module -vi.mock('..', () => ({ +vi.mock('../..', () => ({ useSegmentListContext: (selector: (value: SegmentListContextValue) => unknown) => { return useContextSelector(MockSegmentListContext, selector) }, @@ -53,21 +53,17 @@ describe('ChunkContent', () => { sign_content: 'Test sign content', } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render content in non-QA mode', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, @@ -82,41 +78,34 @@ describe('ChunkContent', () => { // QA mode tests describe('QA Mode', () => { it('should render Q and A labels when answer is present', () => { - // Arrange const qaDetail = { content: 'Question content', sign_content: 'Sign content', answer: 'Answer content', } - // Act render( <ChunkContent detail={qaDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) it('should not render Q and A labels when answer is undefined', () => { - // Arrange & Act render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Assert expect(screen.queryByText('Q')).not.toBeInTheDocument() expect(screen.queryByText('A')).not.toBeInTheDocument() }) }) - // Props tests describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} @@ -126,12 +115,10 @@ describe('ChunkContent', () => { { wrapper: createWrapper() }, ) - // Assert expect(container.querySelector('.custom-class')).toBeInTheDocument() }) it('should handle isFullDocMode=true', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={true} />, { wrapper: createWrapper() }, @@ -142,7 +129,6 @@ describe('ChunkContent', () => { }) it('should handle isFullDocMode=false with isCollapsed=true', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper(true) }, @@ -153,7 +139,6 @@ describe('ChunkContent', () => { }) it('should handle isFullDocMode=false with isCollapsed=false', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper(false) }, @@ -167,13 +152,11 @@ describe('ChunkContent', () => { // Content priority tests describe('Content Priority', () => { it('should prefer sign_content over content when both exist', () => { - // Arrange const detail = { content: 'Regular content', sign_content: 'Sign content', } - // Act const { container } = render( <ChunkContent detail={detail} isFullDocMode={false} />, { wrapper: createWrapper() }, @@ -184,44 +167,36 @@ describe('ChunkContent', () => { }) it('should use content when sign_content is empty', () => { - // Arrange const detail = { content: 'Regular content', sign_content: '', } - // Act const { container } = render( <ChunkContent detail={detail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty content', () => { - // Arrange const emptyDetail = { content: '', sign_content: '', } - // Act const { container } = render( <ChunkContent detail={emptyDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle empty answer in QA mode', () => { - // Arrange const qaDetail = { content: 'Question', sign_content: '', @@ -239,13 +214,11 @@ describe('ChunkContent', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Act rerender( <MockSegmentListContext.Provider value={{ @@ -263,7 +236,6 @@ describe('ChunkContent', () => { </MockSegmentListContext.Provider>, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx rename to web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx index 1ecc2ec597..f0edbb3ebc 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx @@ -4,30 +4,14 @@ import type { Attachment, ChildChunkDetail, ParentMode, SegmentDetailModel } fro import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { ChunkingMode } from '@/models/datasets' -import SegmentCard from './index' +import SegmentCard from '../index' -// Mock react-i18next - external dependency -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { count?: number, ns?: string }) => { - if (key === 'segment.characters') - return options?.count === 1 ? 'character' : 'characters' - if (key === 'segment.childChunks') - return options?.count === 1 ? 'child chunk' : 'child chunks' - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), -})) - -// ============================================================================ // Context Mocks - need to control test scenarios -// ============================================================================ const mockDocForm = { current: ChunkingMode.text } const mockParentMode = { current: 'paragraph' as ParentMode } -vi.mock('../../context', () => ({ +vi.mock('../../../context', () => ({ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { const value: DocumentContextValue = { datasetId: 'test-dataset-id', @@ -40,7 +24,7 @@ vi.mock('../../context', () => ({ })) const mockIsCollapsed = { current: true } -vi.mock('../index', () => ({ +vi.mock('../../index', () => ({ useSegmentListContext: (selector: (value: SegmentListContextValue) => unknown) => { const value: SegmentListContextValue = { isCollapsed: mockIsCollapsed.current, @@ -53,12 +37,10 @@ vi.mock('../index', () => ({ }, })) -// ============================================================================ // Component Mocks - components with complex dependencies -// ============================================================================ // StatusItem uses React Query hooks which require QueryClientProvider -vi.mock('../../../status-item', () => ({ +vi.mock('../../../../status-item', () => ({ default: ({ status, reverse, textCls }: { status: string, reverse?: boolean, textCls?: string }) => ( <div data-testid="status-item" data-status={status} data-reverse={reverse} className={textCls}> Status: @@ -86,10 +68,6 @@ vi.mock('@/app/components/base/markdown', () => ({ ), })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createMockAttachment = (overrides: Partial<Attachment> = {}): Attachment => ({ id: 'attachment-1', name: 'test-image.png', @@ -143,9 +121,7 @@ const createMockSegmentDetail = (overrides: Partial<SegmentDetailModel & { docum const defaultFocused = { segmentIndex: false, segmentContent: false } -// ============================================================================ // Tests -// ============================================================================ describe('SegmentCard', () => { beforeEach(() => { @@ -155,9 +131,6 @@ describe('SegmentCard', () => { mockIsCollapsed.current = true }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render loading skeleton when loading is true', () => { render(<SegmentCard loading={true} focused={defaultFocused} />) @@ -188,7 +161,7 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText('250 characters')).toBeInTheDocument() + expect(screen.getByText('250 datasetDocuments.segment.characters:{"count":250}')).toBeInTheDocument() }) it('should render hit count text', () => { @@ -211,9 +184,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Props Tests - // -------------------------------------------------------------------------- describe('Props', () => { it('should use default empty object when detail is undefined', () => { render(<SegmentCard loading={false} focused={defaultFocused} />) @@ -286,9 +257,6 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- - // State Management Tests - // -------------------------------------------------------------------------- describe('State Management', () => { it('should toggle delete confirmation modal when delete button clicked', async () => { const detail = createMockSegmentDetail() @@ -337,9 +305,6 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- - // Callback Tests - // -------------------------------------------------------------------------- describe('Callbacks', () => { it('should call onClick when card is clicked in general mode', () => { const onClick = vi.fn() @@ -501,9 +466,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Memoization Logic Tests - // -------------------------------------------------------------------------- describe('Memoization Logic', () => { it('should compute isGeneralMode correctly for text mode - show keywords', () => { mockDocForm.current = ChunkingMode.text @@ -550,8 +513,7 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - // ChildSegmentList should render - expect(screen.getByText(/child chunk/i)).toBeInTheDocument() + expect(screen.getByText(/datasetDocuments\.segment\.childChunks/)).toBeInTheDocument() }) it('should compute chunkEdited correctly when updated_at > created_at', () => { @@ -630,13 +592,11 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText('1 character')).toBeInTheDocument() + expect(screen.getByText('1 datasetDocuments.segment.characters:{"count":1}')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Mode-specific Rendering Tests - // -------------------------------------------------------------------------- describe('Mode-specific Rendering', () => { it('should render without padding classes in full-doc mode', () => { mockDocForm.current = ChunkingMode.parentChild @@ -673,9 +633,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Child Segment List Tests - // -------------------------------------------------------------------------- describe('Child Segment List', () => { it('should render ChildSegmentList when in paragraph mode with child chunks', () => { mockDocForm.current = ChunkingMode.parentChild @@ -685,7 +643,7 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument() + expect(screen.getByText(/2 datasetDocuments\.segment\.childChunks/)).toBeInTheDocument() }) it('should not render ChildSegmentList when child_chunks is empty', () => { @@ -733,9 +691,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Keywords Display Tests - // -------------------------------------------------------------------------- describe('Keywords Display', () => { it('should render keywords with # prefix in general mode', () => { mockDocForm.current = ChunkingMode.text @@ -769,9 +725,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Images Display Tests - // -------------------------------------------------------------------------- describe('Images Display', () => { it('should render ImageList when attachments exist', () => { const attachments = [createMockAttachment()] @@ -792,9 +746,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Edge Cases and Error Handling Tests - // -------------------------------------------------------------------------- describe('Edge Cases and Error Handling', () => { it('should handle undefined detail gracefully', () => { render(<SegmentCard loading={false} detail={undefined} focused={defaultFocused} />) @@ -850,7 +802,7 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText('0 characters')).toBeInTheDocument() + expect(screen.getByText('0 datasetDocuments.segment.characters:{"count":0}')).toBeInTheDocument() }) it('should handle zero hit count', () => { @@ -872,9 +824,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Component Integration Tests - // -------------------------------------------------------------------------- describe('Component Integration', () => { it('should render real Tag component with hashtag styling', () => { mockDocForm.current = ChunkingMode.text @@ -963,9 +913,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // All Props Variations Tests - // -------------------------------------------------------------------------- describe('All Props Variations', () => { it('should render correctly with all props provided', () => { mockDocForm.current = ChunkingMode.parentChild @@ -1032,9 +980,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // ChunkContent QA Mode Tests - cover lines 25-49 - // -------------------------------------------------------------------------- describe('ChunkContent QA Mode', () => { it('should render Q and A sections when answer is provided', () => { const detail = createMockSegmentDetail({ @@ -1135,9 +1081,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // ChunkContent Non-QA Mode Tests - ensure full coverage - // -------------------------------------------------------------------------- describe('ChunkContent Non-QA Mode', () => { it('should apply line-clamp-3 in fullDocMode', () => { mockDocForm.current = ChunkingMode.parentChild diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/full-doc-list-skeleton.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx rename to web/app/components/datasets/documents/detail/completed/skeleton/__tests__/full-doc-list-skeleton.spec.tsx index 08ba55cc35..c45637c8f4 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/full-doc-list-skeleton.spec.tsx @@ -1,20 +1,16 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import FullDocListSkeleton from './full-doc-list-skeleton' +import FullDocListSkeleton from '../full-doc-list-skeleton' describe('FullDocListSkeleton', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the correct number of slice elements', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - component renders 15 slices @@ -23,7 +19,6 @@ describe('FullDocListSkeleton', () => { }) it('should render mask overlay element', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - check for the mask overlay element @@ -32,10 +27,8 @@ describe('FullDocListSkeleton', () => { }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) - // Assert const containerElement = container.firstChild as HTMLElement expect(containerElement).toHaveClass('relative') expect(containerElement).toHaveClass('z-10') @@ -48,10 +41,8 @@ describe('FullDocListSkeleton', () => { }) }) - // Structure tests describe('Structure', () => { it('should render slice elements with proper structure', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - each slice should have the content placeholder elements @@ -63,7 +54,6 @@ describe('FullDocListSkeleton', () => { }) it('should render slice with width placeholder elements', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - check for skeleton content width class @@ -72,7 +62,6 @@ describe('FullDocListSkeleton', () => { }) it('should render slice elements with background classes', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - check for skeleton background classes @@ -81,10 +70,8 @@ describe('FullDocListSkeleton', () => { }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently across multiple renders', () => { - // Arrange & Act const { container: container1 } = render(<FullDocListSkeleton />) const { container: container2 } = render(<FullDocListSkeleton />) @@ -95,23 +82,18 @@ describe('FullDocListSkeleton', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rendered multiple times', () => { - // Arrange const { rerender, container } = render(<FullDocListSkeleton />) - // Act rerender(<FullDocListSkeleton />) rerender(<FullDocListSkeleton />) - // Assert const sliceElements = container.querySelectorAll('.flex.flex-col.gap-y-1') expect(sliceElements).toHaveLength(15) }) it('should not have accessibility issues with skeleton content', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - skeleton should be purely visual, no interactive elements diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/general-list-skeleton.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx rename to web/app/components/datasets/documents/detail/completed/skeleton/__tests__/general-list-skeleton.spec.tsx index 0430724671..54e36019c4 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/general-list-skeleton.spec.tsx @@ -1,20 +1,16 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import GeneralListSkeleton, { CardSkelton } from './general-list-skeleton' +import GeneralListSkeleton, { CardSkelton } from '../general-list-skeleton' describe('CardSkelton', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<CardSkelton />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render skeleton rows', () => { - // Arrange & Act const { container } = render(<CardSkelton />) // Assert - component should have skeleton rectangle elements @@ -23,19 +19,15 @@ describe('CardSkelton', () => { }) it('should render with proper container padding', () => { - // Arrange & Act const { container } = render(<CardSkelton />) - // Assert expect(container.querySelector('.p-1')).toBeInTheDocument() expect(container.querySelector('.pb-2')).toBeInTheDocument() }) }) - // Structure tests describe('Structure', () => { it('should render skeleton points as separators', () => { - // Arrange & Act const { container } = render(<CardSkelton />) // Assert - check for opacity class on skeleton points @@ -44,7 +36,6 @@ describe('CardSkelton', () => { }) it('should render width-constrained skeleton elements', () => { - // Arrange & Act const { container } = render(<CardSkelton />) // Assert - check for various width classes @@ -56,18 +47,14 @@ describe('CardSkelton', () => { }) describe('GeneralListSkeleton', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the correct number of list items', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) // Assert - component renders 10 items (Checkbox is a div with shrink-0 and h-4 w-4) @@ -76,19 +63,15 @@ describe('GeneralListSkeleton', () => { }) it('should render mask overlay element', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg') expect(maskElement).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert const containerElement = container.firstChild as HTMLElement expect(containerElement).toHaveClass('relative') expect(containerElement).toHaveClass('z-10') @@ -102,7 +85,6 @@ describe('GeneralListSkeleton', () => { // Checkbox tests describe('Checkboxes', () => { it('should render disabled checkboxes', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) // Assert - Checkbox component uses cursor-not-allowed class when disabled @@ -111,10 +93,8 @@ describe('GeneralListSkeleton', () => { }) it('should render checkboxes with shrink-0 class for consistent sizing', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert const checkboxContainers = container.querySelectorAll('.shrink-0') expect(checkboxContainers.length).toBeGreaterThan(0) }) @@ -123,7 +103,6 @@ describe('GeneralListSkeleton', () => { // Divider tests describe('Dividers', () => { it('should render dividers between items except for the last one', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) // Assert - should have 9 dividers (not after last item) @@ -132,19 +111,15 @@ describe('GeneralListSkeleton', () => { }) }) - // Structure tests describe('Structure', () => { it('should render list items with proper gap styling', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert const listItems = container.querySelectorAll('.gap-x-2') expect(listItems.length).toBeGreaterThan(0) }) it('should render CardSkelton inside each list item', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) // Assert - each list item should contain card skeleton content @@ -153,39 +128,30 @@ describe('GeneralListSkeleton', () => { }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently across multiple renders', () => { - // Arrange & Act const { container: container1 } = render(<GeneralListSkeleton />) const { container: container2 } = render(<GeneralListSkeleton />) - // Assert const checkboxes1 = container1.querySelectorAll('input[type="checkbox"]') const checkboxes2 = container2.querySelectorAll('input[type="checkbox"]') expect(checkboxes1.length).toBe(checkboxes2.length) }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render(<GeneralListSkeleton />) - // Act rerender(<GeneralListSkeleton />) - // Assert const listItems = container.querySelectorAll('.items-start.gap-x-2') expect(listItems).toHaveLength(10) }) it('should not have interactive elements besides disabled checkboxes', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert const buttons = container.querySelectorAll('button') const links = container.querySelectorAll('a') expect(buttons).toHaveLength(0) diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/paragraph-list-skeleton.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx rename to web/app/components/datasets/documents/detail/completed/skeleton/__tests__/paragraph-list-skeleton.spec.tsx index a26b357e1e..556a9de50f 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/paragraph-list-skeleton.spec.tsx @@ -1,20 +1,16 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import ParagraphListSkeleton from './paragraph-list-skeleton' +import ParagraphListSkeleton from '../paragraph-list-skeleton' describe('ParagraphListSkeleton', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the correct number of list items', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) // Assert - component renders 10 items @@ -23,19 +19,15 @@ describe('ParagraphListSkeleton', () => { }) it('should render mask overlay element', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg') expect(maskElement).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert const containerElement = container.firstChild as HTMLElement expect(containerElement).toHaveClass('relative') expect(containerElement).toHaveClass('z-10') @@ -49,7 +41,6 @@ describe('ParagraphListSkeleton', () => { // Checkbox tests describe('Checkboxes', () => { it('should render disabled checkboxes', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) // Assert - Checkbox component uses cursor-not-allowed class when disabled @@ -58,10 +49,8 @@ describe('ParagraphListSkeleton', () => { }) it('should render checkboxes with shrink-0 class for consistent sizing', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert const checkboxContainers = container.querySelectorAll('.shrink-0') expect(checkboxContainers.length).toBeGreaterThan(0) }) @@ -70,7 +59,6 @@ describe('ParagraphListSkeleton', () => { // Divider tests describe('Dividers', () => { it('should render dividers between items except for the last one', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) // Assert - should have 9 dividers (not after last item) @@ -79,10 +67,8 @@ describe('ParagraphListSkeleton', () => { }) }) - // Structure tests describe('Structure', () => { it('should render arrow icon for expand button styling', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) // Assert - paragraph list skeleton has expand button styled area @@ -91,16 +77,13 @@ describe('ParagraphListSkeleton', () => { }) it('should render skeleton rectangles with quaternary text color', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert const skeletonElements = container.querySelectorAll('.bg-text-quaternary') expect(skeletonElements.length).toBeGreaterThan(0) }) it('should render CardSkelton inside each list item', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) // Assert - each list item should contain card skeleton content @@ -109,39 +92,30 @@ describe('ParagraphListSkeleton', () => { }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently across multiple renders', () => { - // Arrange & Act const { container: container1 } = render(<ParagraphListSkeleton />) const { container: container2 } = render(<ParagraphListSkeleton />) - // Assert const items1 = container1.querySelectorAll('.items-start.gap-x-2') const items2 = container2.querySelectorAll('.items-start.gap-x-2') expect(items1.length).toBe(items2.length) }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render(<ParagraphListSkeleton />) - // Act rerender(<ParagraphListSkeleton />) - // Assert const listItems = container.querySelectorAll('.items-start.gap-x-2') expect(listItems).toHaveLength(10) }) it('should not have interactive elements besides disabled checkboxes', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert const buttons = container.querySelectorAll('button') const links = container.querySelectorAll('a') expect(buttons).toHaveLength(0) diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/parent-chunk-card-skeleton.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx rename to web/app/components/datasets/documents/detail/completed/skeleton/__tests__/parent-chunk-card-skeleton.spec.tsx index 71d15a9178..9e6e74e7a6 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/parent-chunk-card-skeleton.spec.tsx @@ -1,23 +1,18 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import ParentChunkCardSkelton from './parent-chunk-card-skeleton' +import ParentChunkCardSkelton from '../parent-chunk-card-skeleton' describe('ParentChunkCardSkelton', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<ParentChunkCardSkelton />) - // Assert expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act render(<ParentChunkCardSkelton />) - // Assert const container = screen.getByTestId('parent-chunk-card-skeleton') expect(container).toHaveClass('flex') expect(container).toHaveClass('flex-col') @@ -25,10 +20,8 @@ describe('ParentChunkCardSkelton', () => { }) it('should render skeleton rectangles', () => { - // Arrange & Act const { container } = render(<ParentChunkCardSkelton />) - // Assert const skeletonRectangles = container.querySelectorAll('.bg-text-quaternary') expect(skeletonRectangles.length).toBeGreaterThan(0) }) @@ -37,7 +30,6 @@ describe('ParentChunkCardSkelton', () => { // i18n tests describe('i18n', () => { it('should render view more button with translated text', () => { - // Arrange & Act render(<ParentChunkCardSkelton />) // Assert - the button should contain translated text @@ -46,28 +38,22 @@ describe('ParentChunkCardSkelton', () => { }) it('should render disabled view more button', () => { - // Arrange & Act render(<ParentChunkCardSkelton />) - // Assert const viewMoreButton = screen.getByRole('button') expect(viewMoreButton).toBeDisabled() }) }) - // Structure tests describe('Structure', () => { it('should render skeleton points as separators', () => { - // Arrange & Act const { container } = render(<ParentChunkCardSkelton />) - // Assert const opacityElements = container.querySelectorAll('.opacity-20') expect(opacityElements.length).toBeGreaterThan(0) }) it('should render width-constrained skeleton elements', () => { - // Arrange & Act const { container } = render(<ParentChunkCardSkelton />) // Assert - check for various width classes @@ -78,50 +64,39 @@ describe('ParentChunkCardSkelton', () => { }) it('should render button with proper styling classes', () => { - // Arrange & Act render(<ParentChunkCardSkelton />) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('system-xs-semibold-uppercase') expect(button).toHaveClass('text-components-button-secondary-accent-text-disabled') }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently across multiple renders', () => { - // Arrange & Act const { container: container1 } = render(<ParentChunkCardSkelton />) const { container: container2 } = render(<ParentChunkCardSkelton />) - // Assert const skeletons1 = container1.querySelectorAll('.bg-text-quaternary') const skeletons2 = container2.querySelectorAll('.bg-text-quaternary') expect(skeletons1.length).toBe(skeletons2.length) }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render(<ParentChunkCardSkelton />) - // Act rerender(<ParentChunkCardSkelton />) - // Assert expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument() const skeletons = container.querySelectorAll('.bg-text-quaternary') expect(skeletons.length).toBeGreaterThan(0) }) it('should have only one interactive element (disabled button)', () => { - // Arrange & Act const { container } = render(<ParentChunkCardSkelton />) - // Assert const buttons = container.querySelectorAll('button') const links = container.querySelectorAll('a') expect(buttons).toHaveLength(1) diff --git a/web/app/components/datasets/documents/detail/embedding/index.spec.tsx b/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/detail/embedding/index.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx index 699de4f12a..b97f824c27 100644 --- a/web/app/components/datasets/documents/detail/embedding/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import type { DocumentContextValue } from '../context' +import type { DocumentContextValue } from '../../context' import type { IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' @@ -9,9 +9,9 @@ import { ProcessMode } from '@/models/datasets' import * as datasetsService from '@/service/datasets' import * as useDataset from '@/service/knowledge/use-dataset' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../create/step-two' -import { DocumentContext } from '../context' -import EmbeddingDetail from './index' +import { IndexingType } from '../../../../create/step-two' +import { DocumentContext } from '../../context' +import EmbeddingDetail from '../index' vi.mock('@/service/datasets') vi.mock('@/service/knowledge/use-dataset') diff --git a/web/app/components/datasets/documents/detail/embedding/components/progress-bar.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/__tests__/progress-bar.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/detail/embedding/components/progress-bar.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/components/__tests__/progress-bar.spec.tsx index b54c8000fe..c4c6501eef 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/progress-bar.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/__tests__/progress-bar.spec.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import ProgressBar from './progress-bar' +import ProgressBar from '../progress-bar' describe('ProgressBar', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/detail/embedding/components/rule-detail.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/__tests__/rule-detail.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/detail/embedding/components/rule-detail.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/components/__tests__/rule-detail.spec.tsx index 138a4eacd8..981f26934c 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/rule-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/__tests__/rule-detail.spec.tsx @@ -3,8 +3,8 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { ProcessMode } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../../create/step-two' -import RuleDetail from './rule-detail' +import { IndexingType } from '../../../../../create/step-two' +import RuleDetail from '../rule-detail' describe('RuleDetail', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/detail/embedding/components/segment-progress.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/__tests__/segment-progress.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/detail/embedding/components/segment-progress.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/components/__tests__/segment-progress.spec.tsx index 1afc2f42f1..8f8ee26140 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/segment-progress.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/__tests__/segment-progress.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import SegmentProgress from './segment-progress' +import SegmentProgress from '../segment-progress' describe('SegmentProgress', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/detail/embedding/components/status-header.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/__tests__/status-header.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/detail/embedding/components/status-header.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/components/__tests__/status-header.spec.tsx index 519d2f3aa8..33d34769e9 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/status-header.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/__tests__/status-header.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import StatusHeader from './status-header' +import StatusHeader from '../status-header' describe('StatusHeader', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.spec.tsx b/web/app/components/datasets/documents/detail/embedding/hooks/__tests__/use-embedding-status.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/hooks/__tests__/use-embedding-status.spec.tsx index 7cadc12dfc..893484afeb 100644 --- a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/hooks/__tests__/use-embedding-status.spec.tsx @@ -12,7 +12,7 @@ import { useInvalidateEmbeddingStatus, usePauseIndexing, useResumeIndexing, -} from './use-embedding-status' +} from '../use-embedding-status' vi.mock('@/service/datasets') diff --git a/web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx b/web/app/components/datasets/documents/detail/embedding/skeleton/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/skeleton/__tests__/index.spec.tsx index e0dc60b668..b350ce8a20 100644 --- a/web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/skeleton/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import EmbeddingSkeleton from './index' +import EmbeddingSkeleton from '../index' // Mock Skeleton components vi.mock('@/app/components/base/skeleton', () => ({ diff --git a/web/app/components/datasets/documents/detail/metadata/index.spec.tsx b/web/app/components/datasets/documents/detail/metadata/__tests__/index.spec.tsx similarity index 93% rename from web/app/components/datasets/documents/detail/metadata/index.spec.tsx rename to web/app/components/datasets/documents/detail/metadata/__tests__/index.spec.tsx index 6efc9661d5..0e385106b6 100644 --- a/web/app/components/datasets/documents/detail/metadata/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/metadata/__tests__/index.spec.tsx @@ -1,16 +1,15 @@ import type { FullDocumentDetail } from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Metadata, { FieldInfo } from './index' +import Metadata, { FieldInfo } from '../index' // Mock document context -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) => { return selector({ datasetId: 'test-dataset-id', documentId: 'test-document-id' }) }, })) -// Mock ToastContext const mockNotify = vi.fn() vi.mock('use-context-selector', async (importOriginal) => { const actual = await importOriginal() as Record<string, unknown> @@ -161,31 +160,24 @@ describe('Metadata', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<Metadata {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render metadata title', () => { - // Arrange & Act render(<Metadata {...defaultProps} />) - // Assert expect(screen.getByText(/metadata\.title/i)).toBeInTheDocument() }) it('should render edit button', () => { - // Arrange & Act render(<Metadata {...defaultProps} />) - // Assert expect(screen.getByText(/operation\.edit/i)).toBeInTheDocument() }) it('should show loading state', () => { - // Arrange & Act render(<Metadata {...defaultProps} loading={true} />) // Assert - Loading component should be rendered, title should not @@ -193,10 +185,8 @@ describe('Metadata', () => { }) it('should display document type icon and text', () => { - // Arrange & Act render(<Metadata {...defaultProps} />) - // Assert expect(screen.getByText('Book')).toBeInTheDocument() }) }) @@ -204,36 +194,28 @@ describe('Metadata', () => { // Edit mode (tests useMetadataState hook integration) describe('Edit Mode', () => { it('should enter edit mode when edit button is clicked', () => { - // Arrange render(<Metadata {...defaultProps} />) - // Act fireEvent.click(screen.getByText(/operation\.edit/i)) - // Assert expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() expect(screen.getByText(/operation\.save/i)).toBeInTheDocument() }) it('should show change link in edit mode', () => { - // Arrange render(<Metadata {...defaultProps} />) - // Act fireEvent.click(screen.getByText(/operation\.edit/i)) - // Assert expect(screen.getByText(/operation\.change/i)).toBeInTheDocument() }) it('should cancel edit and restore values when cancel is clicked', () => { - // Arrange render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Act fireEvent.click(screen.getByText(/operation\.cancel/i)) // Assert - should be back to view mode @@ -241,34 +223,28 @@ describe('Metadata', () => { }) it('should save metadata when save button is clicked', async () => { - // Arrange mockModifyDocMetadata.mockResolvedValueOnce({}) render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Act fireEvent.click(screen.getByText(/operation\.save/i)) - // Assert await waitFor(() => { expect(mockModifyDocMetadata).toHaveBeenCalled() }) }) it('should show success notification after successful save', async () => { - // Arrange mockModifyDocMetadata.mockResolvedValueOnce({}) render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Act fireEvent.click(screen.getByText(/operation\.save/i)) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -279,17 +255,14 @@ describe('Metadata', () => { }) it('should show error notification after failed save', async () => { - // Arrange mockModifyDocMetadata.mockRejectedValueOnce(new Error('Save failed')) render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Act fireEvent.click(screen.getByText(/operation\.save/i)) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -303,49 +276,38 @@ describe('Metadata', () => { // Document type selection (tests DocTypeSelector sub-component integration) describe('Document Type Selection', () => { it('should show doc type selection when no doc_type exists', () => { - // Arrange const docDetail = createMockDocDetail({ doc_type: '' }) - // Act render(<Metadata {...defaultProps} docDetail={docDetail} />) - // Assert expect(screen.getByText(/metadata\.docTypeSelectTitle/i)).toBeInTheDocument() }) it('should show description when no doc_type exists', () => { - // Arrange const docDetail = createMockDocDetail({ doc_type: '' }) - // Act render(<Metadata {...defaultProps} docDetail={docDetail} />) - // Assert expect(screen.getByText(/metadata\.desc/i)).toBeInTheDocument() }) it('should show change link in edit mode when doc_type exists', () => { - // Arrange render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Assert expect(screen.getByText(/operation\.change/i)).toBeInTheDocument() }) it('should show doc type change title after clicking change', () => { - // Arrange render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Act fireEvent.click(screen.getByText(/operation\.change/i)) - // Assert expect(screen.getByText(/metadata\.docTypeChangeTitle/i)).toBeInTheDocument() }) }) @@ -353,7 +315,6 @@ describe('Metadata', () => { // Fixed fields (tests MetadataFieldList sub-component integration) describe('Fixed Fields', () => { it('should render origin info fields', () => { - // Arrange & Act render(<Metadata {...defaultProps} />) // Assert @@ -361,22 +322,17 @@ describe('Metadata', () => { }) it('should render technical parameters fields', () => { - // Arrange & Act render(<Metadata {...defaultProps} />) - // Assert expect(screen.getByText('Segment Count')).toBeInTheDocument() expect(screen.getByText('Hit Count')).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle doc_type as others', () => { - // Arrange const docDetail = createMockDocDetail({ doc_type: 'others' }) - // Act const { container } = render(<Metadata {...defaultProps} docDetail={docDetail} />) // Assert @@ -384,7 +340,6 @@ describe('Metadata', () => { }) it('should handle undefined docDetail gracefully', () => { - // Arrange & Act const { container } = render(<Metadata {...defaultProps} docDetail={undefined} loading={false} />) // Assert @@ -392,7 +347,6 @@ describe('Metadata', () => { }) it('should update document type display when docDetail changes', () => { - // Arrange const { rerender } = render(<Metadata {...defaultProps} />) // Act - verify initial state shows Book @@ -402,7 +356,6 @@ describe('Metadata', () => { const updatedDocDetail = createMockDocDetail({ doc_type: 'paper' }) rerender(<Metadata {...defaultProps} docDetail={updatedDocDetail} />) - // Assert expect(screen.getByText('Paper')).toBeInTheDocument() }) }) @@ -410,13 +363,10 @@ describe('Metadata', () => { // First meta action button describe('First Meta Action Button', () => { it('should show first meta action button when no doc type exists', () => { - // Arrange const docDetail = createMockDocDetail({ doc_type: '' }) - // Act render(<Metadata {...defaultProps} docDetail={docDetail} />) - // Assert expect(screen.getByText(/metadata\.firstMetaAction/i)).toBeInTheDocument() }) }) @@ -436,26 +386,20 @@ describe('FieldInfo', () => { // Rendering describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<FieldInfo {...defaultFieldInfoProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render label', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} />) - // Assert expect(screen.getByText('Test Label')).toBeInTheDocument() }) it('should render displayed value in view mode', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} showEdit={false} />) - // Assert expect(screen.getByText('Test Display Value')).toBeInTheDocument() }) }) @@ -463,15 +407,12 @@ describe('FieldInfo', () => { // Edit mode describe('Edit Mode', () => { it('should render input when showEdit is true and inputType is input', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" onUpdate={vi.fn()} />) - // Assert expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render select when showEdit is true and inputType is select', () => { - // Arrange & Act render( <FieldInfo {...defaultFieldInfoProps} @@ -487,34 +428,26 @@ describe('FieldInfo', () => { }) it('should render textarea when showEdit is true and inputType is textarea', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="textarea" onUpdate={vi.fn()} />) - // Assert expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should call onUpdate when input value changes', () => { - // Arrange const mockOnUpdate = vi.fn() render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" onUpdate={mockOnUpdate} />) - // Act fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Value' } }) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith('New Value') }) it('should call onUpdate when textarea value changes', () => { - // Arrange const mockOnUpdate = vi.fn() render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="textarea" onUpdate={mockOnUpdate} />) - // Act fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Textarea Value' } }) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith('New Textarea Value') }) }) @@ -522,18 +455,14 @@ describe('FieldInfo', () => { // Props describe('Props', () => { it('should render value icon when provided', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} valueIcon={<span data-testid="value-icon">Icon</span>} />) - // Assert expect(screen.getByTestId('value-icon')).toBeInTheDocument() }) it('should use defaultValue when provided', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" defaultValue="Default" onUpdate={vi.fn()} />) - // Assert const input = screen.getByRole('textbox') expect(input).toHaveAttribute('placeholder') }) diff --git a/web/app/components/datasets/documents/detail/segment-add/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/segment-add/index.spec.tsx rename to web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index 2ae1c61da4..7f95e42bb7 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Plan } from '@/app/components/billing/type' -import SegmentAdd, { ProcessStatus } from './index' +import SegmentAdd, { ProcessStatus } from '../index' // Mock provider context let mockPlan = { type: Plan.professional } @@ -57,29 +57,22 @@ describe('SegmentAdd', () => { embedding: false, } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<SegmentAdd {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render add button when no importStatus', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} />) - // Assert expect(screen.getByText(/list\.action\.addButton/i)).toBeInTheDocument() }) it('should render popover for batch add', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} />) - // Assert expect(screen.getByTestId('popover')).toBeInTheDocument() }) }) @@ -87,64 +80,49 @@ describe('SegmentAdd', () => { // Import Status displays describe('Import Status Display', () => { it('should show processing indicator when status is WAITING', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />) - // Assert expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument() }) it('should show processing indicator when status is PROCESSING', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />) - // Assert expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument() }) it('should show completed status with ok button', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.COMPLETED} />) - // Assert expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument() }) it('should show error status with ok button', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.ERROR} />) - // Assert expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument() }) it('should not show add button when importStatus is set', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />) - // Assert expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call showNewSegmentModal when add button is clicked', () => { - // Arrange const mockShowNewSegmentModal = vi.fn() render(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal} />) - // Act fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - // Assert expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1) }) it('should call clearProcessStatus when ok is clicked on completed status', () => { - // Arrange const mockClearProcessStatus = vi.fn() render( <SegmentAdd @@ -154,15 +132,12 @@ describe('SegmentAdd', () => { />, ) - // Act fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) - // Assert expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) }) it('should call clearProcessStatus when ok is clicked on error status', () => { - // Arrange const mockClearProcessStatus = vi.fn() render( <SegmentAdd @@ -172,30 +147,23 @@ describe('SegmentAdd', () => { />, ) - // Act fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) - // Assert expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) }) it('should render batch add option in popover', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} />) - // Assert expect(screen.getByText(/list\.action\.batchAdd/i)).toBeInTheDocument() }) it('should call showBatchModal when batch add is clicked', () => { - // Arrange const mockShowBatchModal = vi.fn() render(<SegmentAdd {...defaultProps} showBatchModal={mockShowBatchModal} />) - // Act fireEvent.click(screen.getByText(/list\.action\.batchAdd/i)) - // Assert expect(mockShowBatchModal).toHaveBeenCalledTimes(1) }) }) @@ -203,27 +171,21 @@ describe('SegmentAdd', () => { // Disabled state (embedding) describe('Embedding State', () => { it('should disable add button when embedding is true', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} embedding={true} />) - // Assert const addButton = screen.getByText(/list\.action\.addButton/i).closest('button') expect(addButton).toBeDisabled() }) it('should disable popover button when embedding is true', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} embedding={true} />) - // Assert expect(screen.getByTestId('popover-btn')).toBeDisabled() }) it('should apply disabled styling when embedding is true', () => { - // Arrange & Act const { container } = render(<SegmentAdd {...defaultProps} embedding={true} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('border-components-button-secondary-border-disabled') }) @@ -232,46 +194,36 @@ describe('SegmentAdd', () => { // Plan upgrade modal describe('Plan Upgrade Modal', () => { it('should show plan upgrade modal when sandbox user tries to add', () => { - // Arrange mockPlan = { type: Plan.sandbox } render(<SegmentAdd {...defaultProps} />) - // Act fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - // Assert expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() }) it('should not call showNewSegmentModal for sandbox users', () => { - // Arrange mockPlan = { type: Plan.sandbox } const mockShowNewSegmentModal = vi.fn() render(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal} />) - // Act fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - // Assert expect(mockShowNewSegmentModal).not.toHaveBeenCalled() }) it('should allow add when billing is disabled regardless of plan', () => { - // Arrange mockPlan = { type: Plan.sandbox } mockEnableBilling = false const mockShowNewSegmentModal = vi.fn() render(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal} />) - // Act fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - // Assert expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1) }) it('should close plan upgrade modal when close button is clicked', () => { - // Arrange mockPlan = { type: Plan.sandbox } render(<SegmentAdd {...defaultProps} />) @@ -279,10 +231,8 @@ describe('SegmentAdd', () => { fireEvent.click(screen.getByText(/list\.action\.addButton/i)) expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() - // Act fireEvent.click(screen.getByTestId('close-modal')) - // Assert expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument() }) }) @@ -290,25 +240,20 @@ describe('SegmentAdd', () => { // Progress bar width tests describe('Progress Bar', () => { it('should show 3/12 width progress bar for WAITING status', () => { - // Arrange & Act const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />) - // Assert const progressBar = container.querySelector('.w-3\\/12') expect(progressBar).toBeInTheDocument() }) it('should show 2/3 width progress bar for PROCESSING status', () => { - // Arrange & Act const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />) - // Assert const progressBar = container.querySelector('.w-2\\/3') expect(progressBar).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle unknown importStatus string', () => { // Arrange & Act - pass unknown status @@ -320,30 +265,24 @@ describe('SegmentAdd', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<SegmentAdd {...defaultProps} />) - // Act rerender(<SegmentAdd {...defaultProps} embedding={true} />) - // Assert const addButton = screen.getByText(/list\.action\.addButton/i).closest('button') expect(addButton).toBeDisabled() }) it('should handle callback change', () => { - // Arrange const mockShowNewSegmentModal1 = vi.fn() const mockShowNewSegmentModal2 = vi.fn() const { rerender } = render( <SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal1} />, ) - // Act rerender(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal2} />) fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - // Assert expect(mockShowNewSegmentModal1).not.toHaveBeenCalled() expect(mockShowNewSegmentModal2).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx rename to web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx index 545a51bd49..e6109132a4 100644 --- a/web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx @@ -1,9 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import DocumentSettings from './document-settings' +import DocumentSettings from '../document-settings' -// Mock next/navigation const mockPush = vi.fn() const mockBack = vi.fn() vi.mock('next/navigation', () => ({ @@ -25,7 +24,6 @@ vi.mock('use-context-selector', async (importOriginal) => { } }) -// Mock hooks const mockInvalidDocumentList = vi.fn() const mockInvalidDocumentDetail = vi.fn() let mockDocumentDetail: Record<string, unknown> | null = { @@ -53,7 +51,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () }), })) -// Mock child components vi.mock('@/app/components/base/app-unavailable', () => ({ default: ({ code, unknownReason }: { code?: number, unknownReason?: string }) => ( <div data-testid="app-unavailable"> @@ -129,43 +126,32 @@ describe('DocumentSettings', () => { documentId: 'document-1', } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<DocumentSettings {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render StepTwo component when data is loaded', () => { - // Arrange & Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('step-two')).toBeInTheDocument() }) it('should render loading when documentDetail is not available', () => { - // Arrange mockDocumentDetail = null - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('loading')).toBeInTheDocument() }) it('should render AppUnavailable when error occurs', () => { - // Arrange mockError = new Error('Error loading document') - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('app-unavailable')).toBeInTheDocument() expect(screen.getByTestId('error-code')).toHaveTextContent('500') }) @@ -174,85 +160,64 @@ describe('DocumentSettings', () => { // Props passing describe('Props Passing', () => { it('should pass datasetId to StepTwo', () => { - // Arrange & Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('dataset-id')).toHaveTextContent('dataset-1') }) it('should pass isSetting true to StepTwo', () => { - // Arrange & Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('is-setting')).toHaveTextContent('true') }) it('should pass isAPIKeySet when embedding model is available', () => { - // Arrange & Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('api-key-set')).toHaveTextContent('true') }) it('should pass data source type to StepTwo', () => { - // Arrange & Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('data-source-type')).toHaveTextContent('upload_file') }) }) - // User Interactions describe('User Interactions', () => { it('should call router.back when cancel is clicked', () => { - // Arrange render(<DocumentSettings {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('cancel-btn')) - // Assert expect(mockBack).toHaveBeenCalled() }) it('should navigate to document page when save is clicked', () => { - // Arrange render(<DocumentSettings {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert expect(mockInvalidDocumentList).toHaveBeenCalled() expect(mockInvalidDocumentDetail).toHaveBeenCalled() expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/document-1') }) it('should show AccountSetting modal when setting button is clicked', () => { - // Arrange render(<DocumentSettings {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('setting-btn')) - // Assert expect(screen.getByTestId('account-setting')).toBeInTheDocument() }) it('should hide AccountSetting modal when close is clicked', async () => { - // Arrange render(<DocumentSettings {...defaultProps} />) fireEvent.click(screen.getByTestId('setting-btn')) expect(screen.getByTestId('account-setting')).toBeInTheDocument() - // Act fireEvent.click(screen.getByTestId('close-setting')) - // Assert expect(screen.queryByTestId('account-setting')).not.toBeInTheDocument() }) }) @@ -260,7 +225,6 @@ describe('DocumentSettings', () => { // Data source types describe('Data Source Types', () => { it('should handle legacy upload_file data source', () => { - // Arrange mockDocumentDetail = { name: 'test-document', data_source_type: 'upload_file', @@ -269,15 +233,12 @@ describe('DocumentSettings', () => { }, } - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('files-count')).toHaveTextContent('1') }) it('should handle website crawl data source', () => { - // Arrange mockDocumentDetail = { name: 'test-website', data_source_type: 'website_crawl', @@ -289,15 +250,12 @@ describe('DocumentSettings', () => { }, } - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('data-source-type')).toHaveTextContent('website_crawl') }) it('should handle local file data source', () => { - // Arrange mockDocumentDetail = { name: 'local-file', data_source_type: 'upload_file', @@ -309,15 +267,12 @@ describe('DocumentSettings', () => { }, } - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('files-count')).toHaveTextContent('1') }) it('should handle online document (Notion) data source', () => { - // Arrange mockDocumentDetail = { name: 'notion-page', data_source_type: 'notion_import', @@ -333,41 +288,32 @@ describe('DocumentSettings', () => { }, } - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('data-source-type')).toHaveTextContent('notion_import') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined data_source_info', () => { - // Arrange mockDocumentDetail = { name: 'test-document', data_source_type: 'upload_file', data_source_info: undefined, } - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('files-count')).toHaveTextContent('0') }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render( <DocumentSettings datasetId="dataset-1" documentId="doc-1" />, ) - // Act rerender(<DocumentSettings datasetId="dataset-2" documentId="doc-2" />) - // Assert expect(screen.getByTestId('step-two')).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/settings/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/detail/settings/index.spec.tsx rename to web/app/components/datasets/documents/detail/settings/__tests__/index.spec.tsx index 3a7c10a0be..e7cd851724 100644 --- a/web/app/components/datasets/documents/detail/settings/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Settings from './index' +import Settings from '../index' // Mock the dataset detail context let mockRuntimeMode: string | undefined = 'general' @@ -11,8 +11,7 @@ vi.mock('@/context/dataset-detail', () => ({ }, })) -// Mock child components -vi.mock('./document-settings', () => ({ +vi.mock('../document-settings', () => ({ default: ({ datasetId, documentId }: { datasetId: string, documentId: string }) => ( <div data-testid="document-settings"> DocumentSettings - @@ -26,7 +25,7 @@ vi.mock('./document-settings', () => ({ ), })) -vi.mock('./pipeline-settings', () => ({ +vi.mock('../pipeline-settings', () => ({ default: ({ datasetId, documentId }: { datasetId: string, documentId: string }) => ( <div data-testid="pipeline-settings"> PipelineSettings - @@ -46,15 +45,12 @@ describe('Settings', () => { mockRuntimeMode = 'general' }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <Settings datasetId="dataset-1" documentId="doc-1" />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) }) @@ -62,25 +58,19 @@ describe('Settings', () => { // Conditional rendering tests describe('Conditional Rendering', () => { it('should render DocumentSettings when runtimeMode is general', () => { - // Arrange mockRuntimeMode = 'general' - // Act render(<Settings datasetId="dataset-1" documentId="doc-1" />) - // Assert expect(screen.getByTestId('document-settings')).toBeInTheDocument() expect(screen.queryByTestId('pipeline-settings')).not.toBeInTheDocument() }) it('should render PipelineSettings when runtimeMode is not general', () => { - // Arrange mockRuntimeMode = 'pipeline' - // Act render(<Settings datasetId="dataset-1" documentId="doc-1" />) - // Assert expect(screen.getByTestId('pipeline-settings')).toBeInTheDocument() expect(screen.queryByTestId('document-settings')).not.toBeInTheDocument() }) @@ -89,37 +79,28 @@ describe('Settings', () => { // Props passing tests describe('Props', () => { it('should pass datasetId and documentId to DocumentSettings', () => { - // Arrange mockRuntimeMode = 'general' - // Act render(<Settings datasetId="test-dataset" documentId="test-document" />) - // Assert expect(screen.getByText(/test-dataset/)).toBeInTheDocument() expect(screen.getByText(/test-document/)).toBeInTheDocument() }) it('should pass datasetId and documentId to PipelineSettings', () => { - // Arrange mockRuntimeMode = 'pipeline' - // Act render(<Settings datasetId="test-dataset" documentId="test-document" />) - // Assert expect(screen.getByText(/test-dataset/)).toBeInTheDocument() expect(screen.getByText(/test-document/)).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined runtimeMode as non-general', () => { - // Arrange mockRuntimeMode = undefined - // Act render(<Settings datasetId="dataset-1" documentId="doc-1" />) // Assert - undefined !== 'general', so PipelineSettings should render @@ -127,16 +108,13 @@ describe('Settings', () => { }) it('should maintain structure when rerendered', () => { - // Arrange mockRuntimeMode = 'general' const { rerender } = render( <Settings datasetId="dataset-1" documentId="doc-1" />, ) - // Act rerender(<Settings datasetId="dataset-2" documentId="doc-2" />) - // Assert expect(screen.getByText(/dataset-2/)).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx similarity index 94% rename from web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx rename to web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx index efec45be0b..9f2ccc0acd 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { PipelineExecutionLogResponse } from '@/models/pipeline' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { DatasourceType } from '@/models/pipeline' -import PipelineSettings from './index' +import PipelineSettings from '../index' // Mock Next.js router const mockPush = vi.fn() @@ -44,7 +44,7 @@ vi.mock('@/service/knowledge/use-document', () => ({ })) // Mock Form component in ProcessDocuments - internal dependencies are too complex -vi.mock('../../../create-from-pipeline/process-documents/form', () => ({ +vi.mock('../../../../create-from-pipeline/process-documents/form', () => ({ default: function MockForm({ ref, initialData, @@ -88,7 +88,7 @@ vi.mock('../../../create-from-pipeline/process-documents/form', () => ({ })) // Mock ChunkPreview - has complex internal state and many dependencies -vi.mock('../../../create-from-pipeline/preview/chunk-preview', () => ({ +vi.mock('../../../../create-from-pipeline/preview/chunk-preview', () => ({ default: function MockChunkPreview({ dataSourceType, localFiles, @@ -123,7 +123,6 @@ vi.mock('../../../create-from-pipeline/preview/chunk-preview', () => ({ }, })) -// Test utilities const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -189,10 +188,8 @@ describe('PipelineSettings', () => { // Test basic rendering with real components describe('Rendering', () => { it('should render without crashing when data is loaded', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Assert - Real LeftHeader should render with correct content @@ -205,7 +202,6 @@ describe('PipelineSettings', () => { }) it('should render Loading component when fetching data', () => { - // Arrange mockUsePipelineExecutionLog.mockReturnValue({ data: undefined, isFetching: true, @@ -213,7 +209,6 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Assert - Loading component should be rendered, not main content @@ -222,7 +217,6 @@ describe('PipelineSettings', () => { }) it('should render AppUnavailable when there is an error', () => { - // Arrange mockUsePipelineExecutionLog.mockReturnValue({ data: undefined, isFetching: false, @@ -230,7 +224,6 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Assert - AppUnavailable should be rendered @@ -238,13 +231,10 @@ describe('PipelineSettings', () => { }) it('should render container with correct CSS classes', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = renderWithProviders(<PipelineSettings {...props} />) - // Assert const mainContainer = container.firstChild as HTMLElement expect(mainContainer).toHaveClass('relative', 'flex', 'min-w-[1024px]') }) @@ -254,10 +244,8 @@ describe('PipelineSettings', () => { // Test real LeftHeader component behavior describe('LeftHeader Integration', () => { it('should render LeftHeader with title prop', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Assert - LeftHeader displays the title @@ -265,10 +253,8 @@ describe('PipelineSettings', () => { }) it('should render back button in LeftHeader', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Assert - Back button should exist with proper aria-label @@ -277,15 +263,12 @@ describe('PipelineSettings', () => { }) it('should call router.back when back button is clicked', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) const backButton = screen.getByRole('button', { name: 'common.operation.back' }) fireEvent.click(backButton) - // Assert expect(mockBack).toHaveBeenCalledTimes(1) }) }) @@ -293,13 +276,10 @@ describe('PipelineSettings', () => { // ==================== Props Testing ==================== describe('Props', () => { it('should pass datasetId and documentId to usePipelineExecutionLog', () => { - // Arrange const props = { datasetId: 'custom-dataset', documentId: 'custom-document' } - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(mockUsePipelineExecutionLog).toHaveBeenCalledWith({ dataset_id: 'custom-dataset', document_id: 'custom-document', @@ -310,7 +290,6 @@ describe('PipelineSettings', () => { // ==================== Memoization - Data Transformation ==================== describe('Memoization - Data Transformation', () => { it('should transform localFile datasource correctly', () => { - // Arrange const mockData = createMockExecutionLogResponse({ datasource_type: DatasourceType.localFile, datasource_info: { @@ -326,16 +305,13 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId('local-files-count')).toHaveTextContent('1') expect(screen.getByTestId('datasource-type')).toHaveTextContent(DatasourceType.localFile) }) it('should transform websiteCrawl datasource correctly', () => { - // Arrange const mockData = createMockExecutionLogResponse({ datasource_type: DatasourceType.websiteCrawl, datasource_info: { @@ -352,16 +328,13 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId('website-pages-count')).toHaveTextContent('1') expect(screen.getByTestId('local-files-count')).toHaveTextContent('0') }) it('should transform onlineDocument datasource correctly', () => { - // Arrange const mockData = createMockExecutionLogResponse({ datasource_type: DatasourceType.onlineDocument, datasource_info: { @@ -376,15 +349,12 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId('online-documents-count')).toHaveTextContent('1') }) it('should transform onlineDrive datasource correctly', () => { - // Arrange const mockData = createMockExecutionLogResponse({ datasource_type: DatasourceType.onlineDrive, datasource_info: { id: 'drive-1', type: 'doc', name: 'Google Doc', size: 1024 }, @@ -396,10 +366,8 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId('online-drive-files-count')).toHaveTextContent('1') }) }) @@ -407,32 +375,26 @@ describe('PipelineSettings', () => { // ==================== User Interactions - Process ==================== describe('User Interactions - Process', () => { it('should trigger form submit when process button is clicked', async () => { - // Arrange mockMutateAsync.mockResolvedValue({}) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Find the "Save and Process" button (from real ProcessDocuments > Actions) const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }) fireEvent.click(processButton) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalled() }) }) it('should call handleProcess with is_preview=false', async () => { - // Arrange mockMutateAsync.mockResolvedValue({}) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -446,36 +408,30 @@ describe('PipelineSettings', () => { }) it('should navigate to documents list after successful process', async () => { - // Arrange mockMutateAsync.mockImplementation((_request, options) => { options?.onSuccess?.() return Promise.resolve({}) }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert await waitFor(() => { expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents') }) }) it('should invalidate document cache after successful process', async () => { - // Arrange mockMutateAsync.mockImplementation((_request, options) => { options?.onSuccess?.() return Promise.resolve({}) }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert await waitFor(() => { expect(mockInvalidDocumentList).toHaveBeenCalled() expect(mockInvalidDocumentDetail).toHaveBeenCalled() @@ -486,30 +442,24 @@ describe('PipelineSettings', () => { // ==================== User Interactions - Preview ==================== describe('User Interactions - Preview', () => { it('should trigger preview when preview button is clicked', async () => { - // Arrange mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalled() }) }) it('should call handlePreviewChunks with is_preview=true', async () => { - // Arrange mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -522,7 +472,6 @@ describe('PipelineSettings', () => { }) it('should update estimateData on successful preview', async () => { - // Arrange const mockOutputs = { chunks: [], total_tokens: 50 } mockMutateAsync.mockImplementation((_req, opts) => { opts?.onSuccess?.({ data: { outputs: mockOutputs } }) @@ -530,11 +479,9 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert await waitFor(() => { expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true') }) @@ -544,7 +491,6 @@ describe('PipelineSettings', () => { // ==================== API Integration ==================== describe('API Integration', () => { it('should pass correct parameters for preview', async () => { - // Arrange const mockData = createMockExecutionLogResponse({ datasource_type: DatasourceType.localFile, datasource_node_id: 'node-xyz', @@ -559,7 +505,6 @@ describe('PipelineSettings', () => { mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) @@ -589,7 +534,6 @@ describe('PipelineSettings', () => { [DatasourceType.onlineDocument, 'online-documents-count', '1'], [DatasourceType.onlineDrive, 'online-drive-files-count', '1'], ])('should handle %s datasource type correctly', (datasourceType, testId, expectedCount) => { - // Arrange const datasourceInfoMap: Record<DatasourceType, Record<string, unknown>> = { [DatasourceType.localFile]: { related_id: 'f1', name: 'file.pdf', extension: 'pdf' }, [DatasourceType.websiteCrawl]: { content: 'c', description: 'd', source_url: 'u', title: 't' }, @@ -608,15 +552,12 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId(testId)).toHaveTextContent(expectedCount) }) it('should show loading state during initial fetch', () => { - // Arrange mockUsePipelineExecutionLog.mockReturnValue({ data: undefined, isFetching: true, @@ -624,15 +565,12 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.queryByTestId('process-form')).not.toBeInTheDocument() }) it('should show error state when API fails', () => { - // Arrange mockUsePipelineExecutionLog.mockReturnValue({ data: undefined, isFetching: false, @@ -640,10 +578,8 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.queryByTestId('process-form')).not.toBeInTheDocument() }) }) @@ -651,18 +587,14 @@ describe('PipelineSettings', () => { // ==================== State Management ==================== describe('State Management', () => { it('should initialize with undefined estimateData', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('false') }) it('should update estimateData after successful preview', async () => { - // Arrange const mockEstimateData = { chunks: [], total_tokens: 50 } mockMutateAsync.mockImplementation((_req, opts) => { opts?.onSuccess?.({ data: { outputs: mockEstimateData } }) @@ -670,26 +602,21 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert await waitFor(() => { expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true') }) }) it('should set isPreview ref to false when process is clicked', async () => { - // Arrange mockMutateAsync.mockResolvedValue({}) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ is_preview: false }), @@ -699,15 +626,12 @@ describe('PipelineSettings', () => { }) it('should set isPreview ref to true when preview is clicked', async () => { - // Arrange mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ is_preview: true }), @@ -765,9 +689,7 @@ describe('PipelineSettings', () => { mockMutateAsync.mockReturnValue(new Promise<void>(() => undefined)) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Click process (not preview) to set isPreview.current = false fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) // Assert - isPending && isPreview.current should be false (true && false = false) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx similarity index 84% rename from web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx rename to web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx index 208b3b3955..9a1ffab673 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx @@ -1,9 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import LeftHeader from './left-header' +import LeftHeader from '../left-header' -// Mock next/navigation const mockBack = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -16,26 +15,20 @@ describe('LeftHeader', () => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<LeftHeader title="Test Title" />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the title', () => { - // Arrange & Act render(<LeftHeader title="My Document Title" />) - // Assert expect(screen.getByText('My Document Title')).toBeInTheDocument() }) it('should render the process documents text', () => { - // Arrange & Act render(<LeftHeader title="Test" />) // Assert - i18n key format @@ -43,54 +36,41 @@ describe('LeftHeader', () => { }) it('should render back button', () => { - // Arrange & Act render(<LeftHeader title="Test" />) - // Assert const backButton = screen.getByRole('button') expect(backButton).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call router.back when back button is clicked', () => { - // Arrange render(<LeftHeader title="Test" />) - // Act const backButton = screen.getByRole('button') fireEvent.click(backButton) - // Assert expect(mockBack).toHaveBeenCalledTimes(1) }) it('should call router.back multiple times on multiple clicks', () => { - // Arrange render(<LeftHeader title="Test" />) - // Act const backButton = screen.getByRole('button') fireEvent.click(backButton) fireEvent.click(backButton) - // Assert expect(mockBack).toHaveBeenCalledTimes(2) }) }) - // Props tests describe('Props', () => { it('should render different titles', () => { - // Arrange const { rerender } = render(<LeftHeader title="First Title" />) expect(screen.getByText('First Title')).toBeInTheDocument() - // Act rerender(<LeftHeader title="Second Title" />) - // Assert expect(screen.getByText('Second Title')).toBeInTheDocument() }) }) @@ -98,55 +78,42 @@ describe('LeftHeader', () => { // Styling tests describe('Styling', () => { it('should have back button with proper styling', () => { - // Arrange & Act render(<LeftHeader title="Test" />) - // Assert const backButton = screen.getByRole('button') expect(backButton).toHaveClass('absolute') expect(backButton).toHaveClass('rounded-full') }) it('should render title with gradient background styling', () => { - // Arrange & Act const { container } = render(<LeftHeader title="Test" />) - // Assert const titleElement = container.querySelector('.bg-pipeline-add-documents-title-bg') expect(titleElement).toBeInTheDocument() }) }) - // Accessibility tests describe('Accessibility', () => { it('should have aria-label on back button', () => { - // Arrange & Act render(<LeftHeader title="Test" />) - // Assert const backButton = screen.getByRole('button') expect(backButton).toHaveAttribute('aria-label') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty title', () => { - // Arrange & Act const { container } = render(<LeftHeader title="" />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<LeftHeader title="Test" />) - // Act rerender(<LeftHeader title="Updated Test" />) - // Assert expect(screen.getByText('Updated Test')).toBeInTheDocument() expect(screen.getByRole('button')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/actions.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx rename to web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/actions.spec.tsx index 67c935a7b8..73782a55ca 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/actions.spec.tsx @@ -1,32 +1,26 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Actions from './actions' +import Actions from '../actions' describe('Actions', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<Actions onProcess={vi.fn()} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render save and process button', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should render button with translated text', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} />) // Assert - i18n key format @@ -34,10 +28,8 @@ describe('Actions', () => { }) it('should render with correct container styling', () => { - // Arrange & Act const { container } = render(<Actions onProcess={vi.fn()} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('items-center') @@ -45,56 +37,42 @@ describe('Actions', () => { }) }) - // User Interactions describe('User Interactions', () => { it('should call onProcess when button is clicked', () => { - // Arrange const mockOnProcess = vi.fn() render(<Actions onProcess={mockOnProcess} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnProcess).toHaveBeenCalledTimes(1) }) it('should not call onProcess when button is disabled', () => { - // Arrange const mockOnProcess = vi.fn() render(<Actions onProcess={mockOnProcess} runDisabled={true} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnProcess).not.toHaveBeenCalled() }) }) - // Props tests describe('Props', () => { it('should disable button when runDisabled is true', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} runDisabled={true} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should enable button when runDisabled is false', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} runDisabled={false} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) it('should enable button when runDisabled is undefined (default)', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) }) @@ -102,7 +80,6 @@ describe('Actions', () => { // Button variant tests describe('Button Styling', () => { it('should render button with primary variant', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} />) // Assert - primary variant buttons have specific classes @@ -111,46 +88,36 @@ describe('Actions', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle multiple rapid clicks', () => { - // Arrange const mockOnProcess = vi.fn() render(<Actions onProcess={mockOnProcess} />) - // Act const button = screen.getByRole('button') fireEvent.click(button) fireEvent.click(button) fireEvent.click(button) - // Assert expect(mockOnProcess).toHaveBeenCalledTimes(3) }) it('should maintain structure when rerendered', () => { - // Arrange const mockOnProcess = vi.fn() const { rerender } = render(<Actions onProcess={mockOnProcess} />) - // Act rerender(<Actions onProcess={mockOnProcess} runDisabled={true} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should handle callback change', () => { - // Arrange const mockOnProcess1 = vi.fn() const mockOnProcess2 = vi.fn() const { rerender } = render(<Actions onProcess={mockOnProcess1} />) - // Act rerender(<Actions onProcess={mockOnProcess2} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnProcess1).not.toHaveBeenCalled() expect(mockOnProcess2).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..227dc63a8c --- /dev/null +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useInputVariables } from '../hooks' + +let mockPipelineId: string | undefined + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id?: string } | null }) => unknown) => + selector({ dataset: mockPipelineId ? { pipeline_id: mockPipelineId } : null }), +})) + +let mockParamsReturn: { + data: Record<string, unknown> | undefined + isFetching: boolean +} + +const mockUsePublishedPipelineProcessingParams = vi.fn( + (_params: { pipeline_id: string, node_id: string }) => mockParamsReturn, +) + +vi.mock('@/service/use-pipeline', () => ({ + usePublishedPipelineProcessingParams: (params: { pipeline_id: string, node_id: string }) => + mockUsePublishedPipelineProcessingParams(params), +})) + +describe('useInputVariables', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPipelineId = 'pipeline-123' + mockParamsReturn = { + data: undefined, + isFetching: false, + } + }) + + // Returns paramsConfig from API + describe('Data Retrieval', () => { + it('should return paramsConfig from API', () => { + const mockConfig = { variables: [{ name: 'var1', type: 'string' }] } + mockParamsReturn = { data: mockConfig, isFetching: false } + + const { result } = renderHook(() => useInputVariables('node-456')) + + expect(result.current.paramsConfig).toEqual(mockConfig) + }) + + it('should return isFetchingParams loading state', () => { + mockParamsReturn = { data: undefined, isFetching: true } + + const { result } = renderHook(() => useInputVariables('node-456')) + + expect(result.current.isFetchingParams).toBe(true) + }) + }) + + // Passes correct parameters to API hook + describe('Parameter Passing', () => { + it('should pass correct pipeline_id and node_id to API hook', () => { + mockPipelineId = 'pipeline-789' + mockParamsReturn = { data: undefined, isFetching: false } + + renderHook(() => useInputVariables('node-abc')) + + expect(mockUsePublishedPipelineProcessingParams).toHaveBeenCalledWith({ + pipeline_id: 'pipeline-789', + node_id: 'node-abc', + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx similarity index 94% rename from web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx rename to web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx index d0d8da43cf..a38672c3dc 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { RAGPipelineVariable } from '@/models/pipeline' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import { PipelineInputVarType } from '@/models/pipeline' -import ProcessDocuments from './index' +import ProcessDocuments from '../index' // Mock dataset detail context - required for useInputVariables hook const mockPipelineId = 'pipeline-123' @@ -22,7 +22,7 @@ vi.mock('@/service/use-pipeline', () => ({ // Mock Form component - internal dependencies (useAppForm, BaseField) are too complex // Keep the mock minimal and focused on testing the integration -vi.mock('../../../../create-from-pipeline/process-documents/form', () => ({ +vi.mock('../../../../../create-from-pipeline/process-documents/form', () => ({ default: function MockForm({ ref, initialData, @@ -72,7 +72,6 @@ vi.mock('../../../../create-from-pipeline/process-documents/form', () => ({ }, })) -// Test utilities const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -131,10 +130,8 @@ describe('ProcessDocuments', () => { // Test basic rendering and component structure describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - verify both Form and Actions are rendered @@ -143,19 +140,15 @@ describe('ProcessDocuments', () => { }) it('should render with correct container structure', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = renderWithProviders(<ProcessDocuments {...props} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex', 'flex-col', 'gap-y-4', 'pt-4') }) it('should render form fields based on variables configuration', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number }), createMockVariable({ variable: 'separator', label: 'Separator', type: PipelineInputVarType.textInput }), @@ -163,7 +156,6 @@ describe('ProcessDocuments', () => { mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - real hooks transform variables to configurations @@ -179,7 +171,6 @@ describe('ProcessDocuments', () => { describe('Props', () => { describe('lastRunInputData', () => { it('should use lastRunInputData as initial form values', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }), ] @@ -187,7 +178,6 @@ describe('ProcessDocuments', () => { const lastRunInputData = { chunk_size: 500 } const props = createDefaultProps({ lastRunInputData }) - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - lastRunInputData should override default_value @@ -196,17 +186,14 @@ describe('ProcessDocuments', () => { }) it('should use default_value when lastRunInputData is empty', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps({ lastRunInputData: {} }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const input = screen.getByTestId('input-chunk_size') as HTMLInputElement expect(input.value).toBe('100') }) @@ -214,52 +201,40 @@ describe('ProcessDocuments', () => { describe('isRunning', () => { it('should enable Actions button when isRunning is false', () => { - // Arrange const props = createDefaultProps({ isRunning: false }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }) expect(processButton).not.toBeDisabled() }) it('should disable Actions button when isRunning is true', () => { - // Arrange const props = createDefaultProps({ isRunning: true }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }) expect(processButton).toBeDisabled() }) it('should disable preview button when isRunning is true', () => { - // Arrange const props = createDefaultProps({ isRunning: true }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert expect(screen.getByTestId('preview-btn')).toBeDisabled() }) }) describe('ref', () => { it('should expose submit method via ref', () => { - // Arrange const ref = { current: null } as React.RefObject<{ submit: () => void } | null> const onSubmit = vi.fn() const props = createDefaultProps({ ref, onSubmit }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert expect(ref.current).not.toBeNull() expect(typeof ref.current?.submit).toBe('function') @@ -277,50 +252,40 @@ describe('ProcessDocuments', () => { describe('User Interactions', () => { describe('onProcess', () => { it('should call onProcess when Save and Process button is clicked', () => { - // Arrange const onProcess = vi.fn() const props = createDefaultProps({ onProcess }) - // Act renderWithProviders(<ProcessDocuments {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert expect(onProcess).toHaveBeenCalledTimes(1) }) it('should not call onProcess when button is disabled due to isRunning', () => { - // Arrange const onProcess = vi.fn() const props = createDefaultProps({ onProcess, isRunning: true }) - // Act renderWithProviders(<ProcessDocuments {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert expect(onProcess).not.toHaveBeenCalled() }) }) describe('onPreview', () => { it('should call onPreview when preview button is clicked', () => { - // Arrange const onPreview = vi.fn() const props = createDefaultProps({ onPreview }) - // Act renderWithProviders(<ProcessDocuments {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert expect(onPreview).toHaveBeenCalledTimes(1) }) }) describe('onSubmit', () => { it('should call onSubmit with form data when form is submitted', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }), ] @@ -328,7 +293,6 @@ describe('ProcessDocuments', () => { const onSubmit = vi.fn() const props = createDefaultProps({ onSubmit }) - // Act renderWithProviders(<ProcessDocuments {...props} />) fireEvent.submit(screen.getByTestId('process-form')) @@ -343,65 +307,53 @@ describe('ProcessDocuments', () => { // Test real hooks transform data correctly describe('Data Transformation', () => { it('should transform text-input variable to string initial value', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput, default_value: 'default' }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const input = screen.getByTestId('input-name') as HTMLInputElement expect(input.defaultValue).toBe('default') }) it('should transform number variable to number initial value', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'count', label: 'Count', type: PipelineInputVarType.number, default_value: '42' }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const input = screen.getByTestId('input-count') as HTMLInputElement expect(input.defaultValue).toBe('42') }) it('should use empty string for text-input without default value', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const input = screen.getByTestId('input-name') as HTMLInputElement expect(input.defaultValue).toBe('') }) it('should prioritize lastRunInputData over default_value', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'size', label: 'Size', type: PipelineInputVarType.number, default_value: '100' }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps({ lastRunInputData: { size: 999 } }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const input = screen.getByTestId('input-size') as HTMLInputElement expect(input.defaultValue).toBe('999') }) @@ -412,11 +364,9 @@ describe('ProcessDocuments', () => { describe('Edge Cases', () => { describe('Empty/Null data handling', () => { it('should handle undefined paramsConfig.variables', () => { - // Arrange mockParamsConfig.mockReturnValue({ variables: undefined }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - should render without fields @@ -425,26 +375,20 @@ describe('ProcessDocuments', () => { }) it('should handle null paramsConfig', () => { - // Arrange mockParamsConfig.mockReturnValue(null) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert expect(screen.getByTestId('process-form')).toBeInTheDocument() }) it('should handle empty variables array', () => { - // Arrange mockParamsConfig.mockReturnValue({ variables: [] }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert expect(screen.getByTestId('process-form')).toBeInTheDocument() expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument() }) @@ -452,7 +396,6 @@ describe('ProcessDocuments', () => { describe('Multiple variables', () => { it('should handle multiple variables of different types', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'text_field', label: 'Text', type: PipelineInputVarType.textInput, default_value: 'hello' }), createMockVariable({ variable: 'number_field', label: 'Number', type: PipelineInputVarType.number, default_value: '123' }), @@ -461,7 +404,6 @@ describe('ProcessDocuments', () => { mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - all fields should be rendered @@ -471,7 +413,6 @@ describe('ProcessDocuments', () => { }) it('should submit all variables data correctly', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'field1', label: 'Field 1', type: PipelineInputVarType.textInput, default_value: 'value1' }), createMockVariable({ variable: 'field2', label: 'Field 2', type: PipelineInputVarType.number, default_value: '42' }), @@ -480,7 +421,6 @@ describe('ProcessDocuments', () => { const onSubmit = vi.fn() const props = createDefaultProps({ onSubmit }) - // Act renderWithProviders(<ProcessDocuments {...props} />) fireEvent.submit(screen.getByTestId('process-form')) @@ -494,7 +434,6 @@ describe('ProcessDocuments', () => { describe('Variable with options (select type)', () => { it('should handle select variable with options', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'mode', @@ -507,10 +446,8 @@ describe('ProcessDocuments', () => { mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert expect(screen.getByTestId('field-mode')).toBeInTheDocument() const input = screen.getByTestId('input-mode') as HTMLInputElement expect(input.defaultValue).toBe('auto') @@ -522,7 +459,6 @@ describe('ProcessDocuments', () => { // Test Form and Actions components work together with real hooks describe('Integration', () => { it('should coordinate form submission flow correctly', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'setting', label: 'Setting', type: PipelineInputVarType.textInput, default_value: 'initial' }), ] @@ -531,7 +467,6 @@ describe('ProcessDocuments', () => { const onSubmit = vi.fn() const props = createDefaultProps({ onProcess, onSubmit }) - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - form is rendered with correct initial data @@ -546,14 +481,12 @@ describe('ProcessDocuments', () => { }) it('should render complete UI with all interactive elements', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'test', label: 'Test Field', type: PipelineInputVarType.textInput }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - all UI elements are present diff --git a/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.ts b/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.ts new file mode 100644 index 0000000000..e31d4ac547 --- /dev/null +++ b/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.ts @@ -0,0 +1,439 @@ +import type { DocumentListQuery } from '../use-document-list-query-state' +import { act, renderHook } from '@testing-library/react' + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import useDocumentListQueryState from '../use-document-list-query-state' + +const mockPush = vi.fn() +const mockSearchParams = new URLSearchParams() + +vi.mock('@/models/datasets', () => ({ + DisplayStatusList: [ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'enabled', + 'disabled', + 'archived', + ], +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => '/datasets/test-id/documents', + useSearchParams: () => mockSearchParams, +})) + +describe('useDocumentListQueryState', () => { + beforeEach(() => { + vi.clearAllMocks() + // Reset mock search params to empty + for (const key of [...mockSearchParams.keys()]) + mockSearchParams.delete(key) + }) + + // Tests for parseParams (exposed via the query property) + describe('parseParams (via query)', () => { + it('should return default query when no search params present', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query).toEqual({ + page: 1, + limit: 10, + keyword: '', + status: 'all', + sort: '-created_at', + }) + }) + + it('should parse page from search params', () => { + mockSearchParams.set('page', '3') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.page).toBe(3) + }) + + it('should default page to 1 when page is zero', () => { + mockSearchParams.set('page', '0') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.page).toBe(1) + }) + + it('should default page to 1 when page is negative', () => { + mockSearchParams.set('page', '-5') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.page).toBe(1) + }) + + it('should default page to 1 when page is NaN', () => { + mockSearchParams.set('page', 'abc') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.page).toBe(1) + }) + + it('should parse limit from search params', () => { + mockSearchParams.set('limit', '50') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(50) + }) + + it('should default limit to 10 when limit is zero', () => { + mockSearchParams.set('limit', '0') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(10) + }) + + it('should default limit to 10 when limit exceeds 100', () => { + mockSearchParams.set('limit', '101') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(10) + }) + + it('should default limit to 10 when limit is negative', () => { + mockSearchParams.set('limit', '-1') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(10) + }) + + it('should accept limit at boundary 100', () => { + mockSearchParams.set('limit', '100') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(100) + }) + + it('should accept limit at boundary 1', () => { + mockSearchParams.set('limit', '1') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(1) + }) + + it('should parse and decode keyword from search params', () => { + mockSearchParams.set('keyword', encodeURIComponent('hello world')) + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.keyword).toBe('hello world') + }) + + it('should return empty keyword when not present', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.keyword).toBe('') + }) + + it('should sanitize status from search params', () => { + mockSearchParams.set('status', 'available') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.status).toBe('available') + }) + + it('should fallback status to all for unknown status', () => { + mockSearchParams.set('status', 'badvalue') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.status).toBe('all') + }) + + it('should resolve active status alias to available', () => { + mockSearchParams.set('status', 'active') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.status).toBe('available') + }) + + it('should parse valid sort value from search params', () => { + mockSearchParams.set('sort', 'hit_count') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.sort).toBe('hit_count') + }) + + it('should default sort to -created_at for invalid sort value', () => { + mockSearchParams.set('sort', 'invalid_sort') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.sort).toBe('-created_at') + }) + + it('should default sort to -created_at when not present', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.sort).toBe('-created_at') + }) + + it.each([ + '-created_at', + 'created_at', + '-hit_count', + 'hit_count', + ] as const)('should accept valid sort value %s', (sortValue) => { + mockSearchParams.set('sort', sortValue) + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.sort).toBe(sortValue) + }) + }) + + // Tests for updateQuery + describe('updateQuery', () => { + it('should call router.push with updated params when page is changed', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ page: 3 }) + }) + + expect(mockPush).toHaveBeenCalledTimes(1) + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('page=3') + }) + + it('should call router.push with scroll false', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ page: 2 }) + }) + + expect(mockPush).toHaveBeenCalledWith( + expect.any(String), + { scroll: false }, + ) + }) + + it('should set status in URL when status is not all', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ status: 'error' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('status=error') + }) + + it('should not set status in URL when status is all', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ status: 'all' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('status=') + }) + + it('should set sort in URL when sort is not the default -created_at', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ sort: 'hit_count' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('sort=hit_count') + }) + + it('should not set sort in URL when sort is default -created_at', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ sort: '-created_at' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('sort=') + }) + + it('should encode keyword in URL when keyword is provided', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ keyword: 'test query' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + // Source code applies encodeURIComponent before setting in URLSearchParams + expect(pushedUrl).toContain('keyword=') + const params = new URLSearchParams(pushedUrl.split('?')[1]) + // params.get decodes one layer, but the value was pre-encoded with encodeURIComponent + expect(decodeURIComponent(params.get('keyword')!)).toBe('test query') + }) + + it('should remove keyword from URL when keyword is empty', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ keyword: '' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('keyword=') + }) + + it('should sanitize invalid status to all and not include in URL', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ status: 'invalidstatus' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('status=') + }) + + it('should sanitize invalid sort to -created_at and not include in URL', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ sort: 'invalidsort' as DocumentListQuery['sort'] }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('sort=') + }) + + it('should omit page and limit when they are default and no keyword', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ page: 1, limit: 10 }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('page=') + expect(pushedUrl).not.toContain('limit=') + }) + + it('should include page and limit when page is greater than 1', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ page: 2 }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('page=2') + expect(pushedUrl).toContain('limit=10') + }) + + it('should include page and limit when limit is non-default', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ limit: 25 }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('page=1') + expect(pushedUrl).toContain('limit=25') + }) + + it('should include page and limit when keyword is provided', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ keyword: 'search' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('page=1') + expect(pushedUrl).toContain('limit=10') + }) + + it('should use pathname prefix in pushed URL', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ page: 2 }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toMatch(/^\/datasets\/test-id\/documents/) + }) + + it('should push path without query string when all values are defaults', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({}) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toBe('/datasets/test-id/documents') + }) + }) + + // Tests for resetQuery + describe('resetQuery', () => { + it('should push URL with default query params when called', () => { + mockSearchParams.set('page', '5') + mockSearchParams.set('status', 'error') + + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.resetQuery() + }) + + expect(mockPush).toHaveBeenCalledTimes(1) + const pushedUrl = mockPush.mock.calls[0][0] as string + // Default query has all defaults, so no params should be in the URL + expect(pushedUrl).toBe('/datasets/test-id/documents') + }) + + it('should call router.push with scroll false when resetting', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.resetQuery() + }) + + expect(mockPush).toHaveBeenCalledWith( + expect.any(String), + { scroll: false }, + ) + }) + }) + + // Tests for return value stability + describe('return value', () => { + it('should return query, updateQuery, and resetQuery', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current).toHaveProperty('query') + expect(result.current).toHaveProperty('updateQuery') + expect(result.current).toHaveProperty('resetQuery') + expect(typeof result.current.updateQuery).toBe('function') + expect(typeof result.current.resetQuery).toBe('function') + }) + }) +}) diff --git a/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts b/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts new file mode 100644 index 0000000000..34911e9e9c --- /dev/null +++ b/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts @@ -0,0 +1,711 @@ +import type { DocumentListQuery } from '../use-document-list-query-state' +import type { DocumentListResponse } from '@/models/datasets' + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useDocumentsPageState } from '../use-documents-page-state' + +const mockUpdateQuery = vi.fn() +const mockResetQuery = vi.fn() +let mockQuery: DocumentListQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' } + +vi.mock('@/models/datasets', () => ({ + DisplayStatusList: [ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'enabled', + 'disabled', + 'archived', + ], +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/datasets/test-id/documents', + useSearchParams: () => new URLSearchParams(), +})) + +// Mock ahooks debounce utilities: required because tests capture the debounce +// callback reference to invoke it synchronously, bypassing real timer delays. +let capturedDebounceFnCallback: (() => void) | null = null + +vi.mock('ahooks', () => ({ + useDebounce: (value: unknown, _options?: { wait?: number }) => value, + useDebounceFn: (fn: () => void, _options?: { wait?: number }) => { + capturedDebounceFnCallback = fn + return { run: fn, cancel: vi.fn(), flush: vi.fn() } + }, +})) + +// Mock the dependent hook +vi.mock('../use-document-list-query-state', () => ({ + default: () => ({ + query: mockQuery, + updateQuery: mockUpdateQuery, + resetQuery: mockResetQuery, + }), +})) + +// Factory for creating DocumentListResponse test data +function createDocumentListResponse(overrides: Partial<DocumentListResponse> = {}): DocumentListResponse { + return { + data: [], + has_more: false, + total: 0, + page: 1, + limit: 10, + ...overrides, + } +} + +// Factory for creating a minimal document item +function createDocumentItem(overrides: Record<string, unknown> = {}) { + return { + id: `doc-${Math.random().toString(36).slice(2, 8)}`, + name: 'test-doc.txt', + indexing_status: 'completed' as string, + display_status: 'available' as string, + enabled: true, + archived: false, + word_count: 100, + created_at: Date.now(), + updated_at: Date.now(), + created_from: 'web' as const, + created_by: 'user-1', + dataset_process_rule_id: 'rule-1', + doc_form: 'text_model' as const, + doc_language: 'en', + position: 1, + data_source_type: 'upload_file', + ...overrides, + } +} + +describe('useDocumentsPageState', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedDebounceFnCallback = null + mockQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' } + }) + + // Initial state verification + describe('initial state', () => { + it('should return correct initial search state', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.inputValue).toBe('') + expect(result.current.searchValue).toBe('') + expect(result.current.debouncedSearchValue).toBe('') + }) + + it('should return correct initial filter and sort state', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.statusFilterValue).toBe('all') + expect(result.current.sortValue).toBe('-created_at') + expect(result.current.normalizedStatusFilterValue).toBe('all') + }) + + it('should return correct initial pagination state', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + // page is query.page - 1 = 0 + expect(result.current.currPage).toBe(0) + expect(result.current.limit).toBe(10) + }) + + it('should return correct initial selection state', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.selectedIds).toEqual([]) + }) + + it('should return correct initial polling state', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.timerCanRun).toBe(true) + }) + + it('should initialize from query when query has keyword', () => { + mockQuery = { ...mockQuery, keyword: 'initial search' } + + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.inputValue).toBe('initial search') + expect(result.current.searchValue).toBe('initial search') + }) + + it('should initialize pagination from query with non-default page', () => { + mockQuery = { ...mockQuery, page: 3, limit: 25 } + + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.currPage).toBe(2) // page - 1 + expect(result.current.limit).toBe(25) + }) + + it('should initialize status filter from query', () => { + mockQuery = { ...mockQuery, status: 'error' } + + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.statusFilterValue).toBe('error') + }) + + it('should initialize sort from query', () => { + mockQuery = { ...mockQuery, sort: 'hit_count' } + + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.sortValue).toBe('hit_count') + }) + }) + + // Handler behaviors + describe('handleInputChange', () => { + it('should update input value when called', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleInputChange('new value') + }) + + expect(result.current.inputValue).toBe('new value') + }) + + it('should trigger debounced search callback when called', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + // First call sets inputValue and triggers the debounced fn + act(() => { + result.current.handleInputChange('search term') + }) + + // The debounced fn captures inputValue from its render closure. + // After re-render with new inputValue, calling the captured callback again + // should reflect the updated state. + act(() => { + if (capturedDebounceFnCallback) + capturedDebounceFnCallback() + }) + + expect(result.current.searchValue).toBe('search term') + }) + }) + + describe('handleStatusFilterChange', () => { + it('should update status filter value when called with valid status', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('error') + }) + + expect(result.current.statusFilterValue).toBe('error') + }) + + it('should reset page to 0 when status filter changes', () => { + mockQuery = { ...mockQuery, page: 3 } + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('error') + }) + + expect(result.current.currPage).toBe(0) + }) + + it('should call updateQuery with sanitized status and page 1', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('error') + }) + + expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'error', page: 1 }) + }) + + it('should sanitize invalid status to all', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('invalid') + }) + + expect(result.current.statusFilterValue).toBe('all') + expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 }) + }) + }) + + describe('handleStatusFilterClear', () => { + it('should set status to all and reset page when status is not all', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + // First set a non-all status + act(() => { + result.current.handleStatusFilterChange('error') + }) + vi.clearAllMocks() + + // Then clear + act(() => { + result.current.handleStatusFilterClear() + }) + + expect(result.current.statusFilterValue).toBe('all') + expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 }) + }) + + it('should not call updateQuery when status is already all', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterClear() + }) + + expect(mockUpdateQuery).not.toHaveBeenCalled() + }) + }) + + describe('handleSortChange', () => { + it('should update sort value and call updateQuery when value changes', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleSortChange('hit_count') + }) + + expect(result.current.sortValue).toBe('hit_count') + expect(mockUpdateQuery).toHaveBeenCalledWith({ sort: 'hit_count', page: 1 }) + }) + + it('should reset page to 0 when sort changes', () => { + mockQuery = { ...mockQuery, page: 5 } + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleSortChange('hit_count') + }) + + expect(result.current.currPage).toBe(0) + }) + + it('should not call updateQuery when sort value is same as current', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleSortChange('-created_at') + }) + + expect(mockUpdateQuery).not.toHaveBeenCalled() + }) + }) + + describe('handlePageChange', () => { + it('should update current page and call updateQuery', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handlePageChange(2) + }) + + expect(result.current.currPage).toBe(2) + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 }) // newPage + 1 + }) + + it('should handle page 0 (first page)', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handlePageChange(0) + }) + + expect(result.current.currPage).toBe(0) + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 1 }) + }) + }) + + describe('handleLimitChange', () => { + it('should update limit, reset page to 0, and call updateQuery', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleLimitChange(25) + }) + + expect(result.current.limit).toBe(25) + expect(result.current.currPage).toBe(0) + expect(mockUpdateQuery).toHaveBeenCalledWith({ limit: 25, page: 1 }) + }) + }) + + // Selection state + describe('selection state', () => { + it('should update selectedIds via setSelectedIds', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.setSelectedIds(['doc-1', 'doc-2']) + }) + + expect(result.current.selectedIds).toEqual(['doc-1', 'doc-2']) + }) + }) + + // Polling state management + describe('updatePollingState', () => { + it('should not update timer when documentsRes is undefined', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState(undefined) + }) + + // timerCanRun remains true (initial value) + expect(result.current.timerCanRun).toBe(true) + }) + + it('should not update timer when documentsRes.data is undefined', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState({ data: undefined } as unknown as DocumentListResponse) + }) + + expect(result.current.timerCanRun).toBe(true) + }) + + it('should set timerCanRun to false when all documents are completed and status filter is all', () => { + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'completed' }), + createDocumentItem({ indexing_status: 'completed' }), + ] as DocumentListResponse['data'], + total: 2, + }) + + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState(response) + }) + + expect(result.current.timerCanRun).toBe(false) + }) + + it('should set timerCanRun to true when some documents are not completed', () => { + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'completed' }), + createDocumentItem({ indexing_status: 'indexing' }), + ] as DocumentListResponse['data'], + total: 2, + }) + + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState(response) + }) + + expect(result.current.timerCanRun).toBe(true) + }) + + it('should count paused documents as completed for polling purposes', () => { + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'paused' }), + createDocumentItem({ indexing_status: 'completed' }), + ] as DocumentListResponse['data'], + total: 2, + }) + + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState(response) + }) + + // All docs are "embedded" (completed, paused, error), so hasIncomplete = false + // statusFilter is 'all', so shouldForcePolling = false + expect(result.current.timerCanRun).toBe(false) + }) + + it('should count error documents as completed for polling purposes', () => { + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'error' }), + createDocumentItem({ indexing_status: 'completed' }), + ] as DocumentListResponse['data'], + total: 2, + }) + + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState(response) + }) + + expect(result.current.timerCanRun).toBe(false) + }) + + it('should force polling when status filter is a transient status (queuing)', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + // Set status filter to queuing + act(() => { + result.current.handleStatusFilterChange('queuing') + }) + + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'completed' }), + ] as DocumentListResponse['data'], + total: 1, + }) + + act(() => { + result.current.updatePollingState(response) + }) + + // shouldForcePolling = true (queuing is transient), hasIncomplete = false + // timerCanRun = true || false = true + expect(result.current.timerCanRun).toBe(true) + }) + + it('should force polling when status filter is indexing', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('indexing') + }) + + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'completed' }), + ] as DocumentListResponse['data'], + total: 1, + }) + + act(() => { + result.current.updatePollingState(response) + }) + + expect(result.current.timerCanRun).toBe(true) + }) + + it('should force polling when status filter is paused', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('paused') + }) + + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'paused' }), + ] as DocumentListResponse['data'], + total: 1, + }) + + act(() => { + result.current.updatePollingState(response) + }) + + expect(result.current.timerCanRun).toBe(true) + }) + + it('should not force polling when status filter is a non-transient status (error)', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('error') + }) + + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'error' }), + ] as DocumentListResponse['data'], + total: 1, + }) + + act(() => { + result.current.updatePollingState(response) + }) + + // shouldForcePolling = false (error is not transient), hasIncomplete = false (error is embedded) + expect(result.current.timerCanRun).toBe(false) + }) + + it('should set timerCanRun to true when data is empty and filter is transient', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('indexing') + }) + + const response = createDocumentListResponse({ data: [] as DocumentListResponse['data'], total: 0 }) + + act(() => { + result.current.updatePollingState(response) + }) + + // shouldForcePolling = true (indexing is transient), hasIncomplete = false (0 !== 0 is false) + expect(result.current.timerCanRun).toBe(true) + }) + }) + + // Page adjustment + describe('adjustPageForTotal', () => { + it('should not adjust page when documentsRes is undefined', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.adjustPageForTotal(undefined) + }) + + expect(result.current.currPage).toBe(0) + }) + + it('should not adjust page when currPage is within total pages', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + const response = createDocumentListResponse({ total: 20 }) + + act(() => { + result.current.adjustPageForTotal(response) + }) + + // currPage is 0, totalPages is 2, so no adjustment needed + expect(result.current.currPage).toBe(0) + }) + + it('should adjust page to last page when currPage exceeds total pages', () => { + mockQuery = { ...mockQuery, page: 6 } + const { result } = renderHook(() => useDocumentsPageState()) + + // currPage should be 5 (page - 1) + expect(result.current.currPage).toBe(5) + + const response = createDocumentListResponse({ total: 30 }) // 30/10 = 3 pages + + act(() => { + result.current.adjustPageForTotal(response) + }) + + // currPage (5) + 1 > totalPages (3), so adjust to totalPages - 1 = 2 + expect(result.current.currPage).toBe(2) + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 }) // handlePageChange passes newPage + 1 + }) + + it('should adjust page to 0 when total is 0 and currPage > 0', () => { + mockQuery = { ...mockQuery, page: 3 } + const { result } = renderHook(() => useDocumentsPageState()) + + const response = createDocumentListResponse({ total: 0 }) + + act(() => { + result.current.adjustPageForTotal(response) + }) + + // totalPages = 0, so adjust to max(0 - 1, 0) = 0 + expect(result.current.currPage).toBe(0) + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 1 }) + }) + + it('should not adjust page when currPage is 0 even if total is 0', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + const response = createDocumentListResponse({ total: 0 }) + + act(() => { + result.current.adjustPageForTotal(response) + }) + + // currPage is 0, condition is currPage > 0 so no adjustment + expect(mockUpdateQuery).not.toHaveBeenCalled() + }) + }) + + // Normalized status filter value + describe('normalizedStatusFilterValue', () => { + it('should return all for default status', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.normalizedStatusFilterValue).toBe('all') + }) + + it('should normalize enabled to available', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('enabled') + }) + + expect(result.current.normalizedStatusFilterValue).toBe('available') + }) + + it('should return non-aliased status as-is', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('error') + }) + + expect(result.current.normalizedStatusFilterValue).toBe('error') + }) + }) + + // Return value shape + describe('return value', () => { + it('should return all expected properties', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + // Search state + expect(result.current).toHaveProperty('inputValue') + expect(result.current).toHaveProperty('searchValue') + expect(result.current).toHaveProperty('debouncedSearchValue') + expect(result.current).toHaveProperty('handleInputChange') + + // Filter & sort state + expect(result.current).toHaveProperty('statusFilterValue') + expect(result.current).toHaveProperty('sortValue') + expect(result.current).toHaveProperty('normalizedStatusFilterValue') + expect(result.current).toHaveProperty('handleStatusFilterChange') + expect(result.current).toHaveProperty('handleStatusFilterClear') + expect(result.current).toHaveProperty('handleSortChange') + + // Pagination state + expect(result.current).toHaveProperty('currPage') + expect(result.current).toHaveProperty('limit') + expect(result.current).toHaveProperty('handlePageChange') + expect(result.current).toHaveProperty('handleLimitChange') + + // Selection state + expect(result.current).toHaveProperty('selectedIds') + expect(result.current).toHaveProperty('setSelectedIds') + + // Polling state + expect(result.current).toHaveProperty('timerCanRun') + expect(result.current).toHaveProperty('updatePollingState') + expect(result.current).toHaveProperty('adjustPageForTotal') + }) + + it('should have function types for all handlers', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(typeof result.current.handleInputChange).toBe('function') + expect(typeof result.current.handleStatusFilterChange).toBe('function') + expect(typeof result.current.handleStatusFilterClear).toBe('function') + expect(typeof result.current.handleSortChange).toBe('function') + expect(typeof result.current.handlePageChange).toBe('function') + expect(typeof result.current.handleLimitChange).toBe('function') + expect(typeof result.current.setSelectedIds).toBe('function') + expect(typeof result.current.updatePollingState).toBe('function') + expect(typeof result.current.adjustPageForTotal).toBe('function') + }) + }) +}) diff --git a/web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..9b89cab7a0 --- /dev/null +++ b/web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts @@ -0,0 +1,119 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useIndexStatus } from '../hooks' + +// Explicit react-i18next mock so the test stays portable +// even if the global vitest.setup changes. + +describe('useIndexStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify the hook returns all expected status keys + it('should return all expected status keys', () => { + const { result } = renderHook(() => useIndexStatus()) + + const expectedKeys = ['queuing', 'indexing', 'paused', 'error', 'available', 'enabled', 'disabled', 'archived'] + const keys = Object.keys(result.current) + expect(keys).toEqual(expect.arrayContaining(expectedKeys)) + }) + + // Verify each status entry has the correct color + describe('colors', () => { + it('should return orange color for queuing', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.queuing.color).toBe('orange') + }) + + it('should return blue color for indexing', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.indexing.color).toBe('blue') + }) + + it('should return orange color for paused', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.paused.color).toBe('orange') + }) + + it('should return red color for error', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.error.color).toBe('red') + }) + + it('should return green color for available', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.available.color).toBe('green') + }) + + it('should return green color for enabled', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.enabled.color).toBe('green') + }) + + it('should return gray color for disabled', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.disabled.color).toBe('gray') + }) + + it('should return gray color for archived', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.archived.color).toBe('gray') + }) + }) + + // Verify each status entry has translated text (global mock returns ns.key format) + describe('translated text', () => { + it('should return translated text for queuing', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.queuing.text).toBe('datasetDocuments.list.status.queuing') + }) + + it('should return translated text for indexing', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.indexing.text).toBe('datasetDocuments.list.status.indexing') + }) + + it('should return translated text for paused', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.paused.text).toBe('datasetDocuments.list.status.paused') + }) + + it('should return translated text for error', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.error.text).toBe('datasetDocuments.list.status.error') + }) + + it('should return translated text for available', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.available.text).toBe('datasetDocuments.list.status.available') + }) + + it('should return translated text for enabled', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.enabled.text).toBe('datasetDocuments.list.status.enabled') + }) + + it('should return translated text for disabled', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.disabled.text).toBe('datasetDocuments.list.status.disabled') + }) + + it('should return translated text for archived', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.archived.text).toBe('datasetDocuments.list.status.archived') + }) + }) + + // Verify each entry has both color and text properties + it('should return objects with color and text properties for every status', () => { + const { result } = renderHook(() => useIndexStatus()) + + for (const key of Object.keys(result.current) as Array<keyof typeof result.current>) { + expect(result.current[key]).toHaveProperty('color') + expect(result.current[key]).toHaveProperty('text') + expect(typeof result.current[key].color).toBe('string') + expect(typeof result.current[key].text).toBe('string') + } + }) +}) diff --git a/web/app/components/datasets/documents/status-item/index.spec.tsx b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/datasets/documents/status-item/index.spec.tsx rename to web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx index ff02bef11b..ce31bdc62f 100644 --- a/web/app/components/datasets/documents/status-item/index.spec.tsx +++ b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx @@ -1,16 +1,8 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import StatusItem from './index' +import StatusItem from '../index' -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock ToastContext const mockNotify = vi.fn() vi.mock('use-context-selector', () => ({ createContext: (defaultValue: unknown) => React.createContext(defaultValue), @@ -21,7 +13,7 @@ vi.mock('use-context-selector', () => ({ })) // Mock useIndexStatus hook -vi.mock('./hooks', () => ({ +vi.mock('../hooks', () => ({ useIndexStatus: () => ({ queuing: { text: 'Queuing', color: 'orange' }, indexing: { text: 'Indexing', color: 'blue' }, @@ -34,7 +26,6 @@ vi.mock('./hooks', () => ({ }), })) -// Mock service hooks const mockEnable = vi.fn() const mockDisable = vi.fn() const mockDelete = vi.fn() @@ -361,7 +352,7 @@ describe('StatusItem', () => { }) expect(mockNotify).toHaveBeenCalledWith({ type: 'success', - message: 'actionMsg.modifiedSuccessfully', + message: 'common.actionMsg.modifiedSuccessfully', }) vi.useRealTimers() }) @@ -421,7 +412,7 @@ describe('StatusItem', () => { }) expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'actionMsg.modifiedUnsuccessfully', + message: 'common.actionMsg.modifiedUnsuccessfully', }) vi.useRealTimers() }) diff --git a/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/__tests__/Form.spec.tsx similarity index 98% rename from web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx rename to web/app/components/datasets/external-api/external-api-modal/__tests__/Form.spec.tsx index 346bcd00b7..fd23a18365 100644 --- a/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/__tests__/Form.spec.tsx @@ -1,7 +1,7 @@ -import type { CreateExternalAPIReq, FormSchema } from '../declarations' +import type { CreateExternalAPIReq, FormSchema } from '../../declarations' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Form from './Form' +import Form from '../Form' // Mock context for i18n doc link vi.mock('@/context/i18n', () => ({ diff --git a/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/external-api/external-api-modal/index.spec.tsx rename to web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx index 94c4deab04..a631de3ea0 100644 --- a/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx @@ -1,17 +1,16 @@ -import type { CreateExternalAPIReq } from '../declarations' +import type { CreateExternalAPIReq } from '../../declarations' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import mocked service import { createExternalAPI } from '@/service/datasets' -import AddExternalAPIModal from './index' +import AddExternalAPIModal from '../index' // Mock API service vi.mock('@/service/datasets', () => ({ createExternalAPI: vi.fn(), })) -// Mock toast context const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ useToastContext: () => ({ diff --git a/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx b/web/app/components/datasets/external-api/external-api-panel/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/external-api/external-api-panel/index.spec.tsx rename to web/app/components/datasets/external-api/external-api-panel/__tests__/index.spec.tsx index 291b7516c3..eb7c0558ac 100644 --- a/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-api-panel/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import type { ExternalAPIItem } from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import ExternalAPIPanel from './index' +import ExternalAPIPanel from '../index' // Mock external contexts (only mock context providers, not base components) const mockSetShowExternalKnowledgeAPIModal = vi.fn() @@ -28,7 +28,7 @@ vi.mock('@/context/i18n', () => ({ })) // Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies -vi.mock('../external-knowledge-api-card', () => ({ +vi.mock('../../external-knowledge-api-card', () => ({ default: ({ api }: { api: ExternalAPIItem }) => ( <div data-testid={`api-card-${api.id}`}>{api.name}</div> ), diff --git a/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx b/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx rename to web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx index f8aacde3e1..bc1c923876 100644 --- a/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Import mocked services import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets' -import ExternalKnowledgeAPICard from './index' +import ExternalKnowledgeAPICard from '../index' // Mock API services vi.mock('@/service/datasets', () => ({ diff --git a/web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx rename to web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx index ffb86336f9..ccd637887b 100644 --- a/web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx @@ -3,9 +3,8 @@ import type { ExternalAPIItem } from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createExternalKnowledgeBase } from '@/service/datasets' -import ExternalKnowledgeBaseConnector from './index' +import ExternalKnowledgeBaseConnector from '../index' -// Mock next/navigation const mockRouterBack = vi.fn() const mockReplace = vi.fn() vi.mock('next/navigation', () => ({ @@ -22,7 +21,6 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`, })) -// Mock toast context const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ useToastContext: () => ({ @@ -262,7 +260,6 @@ describe('ExternalKnowledgeBaseConnector', () => { expect(connectButton).not.toBeDisabled() }) - // Click connect const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') await user.click(connectButton!) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx new file mode 100644 index 0000000000..3b8b35a5b7 --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Explicit react-i18next mock so the test stays portable +// even if the global vitest.setup changes. + +// Hoisted mocks +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + refresh: vi.fn(), + setShowExternalKnowledgeAPIModal: vi.fn(), + mutateExternalKnowledgeApis: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalKnowledgeAPIModal: mocks.setShowExternalKnowledgeAPIModal, + }), +})) + +vi.mock('@/context/external-knowledge-api-context', () => ({ + useExternalKnowledgeApi: () => ({ + mutateExternalKnowledgeApis: mocks.mutateExternalKnowledgeApis, + }), +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({ + ApiConnectionMod: (props: Record<string, unknown>) => <span data-testid="api-icon" {...props} />, +})) + +const { default: ExternalApiSelect } = await import('../ExternalApiSelect') + +describe('ExternalApiSelect', () => { + const items = [ + { value: 'api-1', name: 'API One', url: 'https://api1.com' }, + { value: 'api-2', name: 'API Two', url: 'https://api2.com' }, + ] + const onSelect = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should show placeholder when no value selected', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + expect(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')).toBeInTheDocument() + }) + + it('should show selected item name when value matches', () => { + render(<ExternalApiSelect items={items} value="api-1" onSelect={onSelect} />) + expect(screen.getByText('API One')).toBeInTheDocument() + }) + + it('should not show dropdown initially', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + expect(screen.queryByText('API Two')).not.toBeInTheDocument() + }) + }) + + describe('dropdown interactions', () => { + it('should open dropdown on click', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')) + expect(screen.getByText('API One')).toBeInTheDocument() + expect(screen.getByText('API Two')).toBeInTheDocument() + }) + + it('should close dropdown and call onSelect when item clicked', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + // Open + fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')) + // Select + fireEvent.click(screen.getByText('API Two')) + expect(onSelect).toHaveBeenCalledWith(items[1]) + // Dropdown should close - selected name should show + expect(screen.getByText('API Two')).toBeInTheDocument() + }) + + it('should show add new API option in dropdown', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')) + expect(screen.getByText('dataset.createNewExternalAPI')).toBeInTheDocument() + }) + + it('should call setShowExternalKnowledgeAPIModal when add new clicked', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')) + fireEvent.click(screen.getByText('dataset.createNewExternalAPI')) + expect(mocks.setShowExternalKnowledgeAPIModal).toHaveBeenCalledOnce() + }) + + it('should show item URLs in dropdown', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')) + expect(screen.getByText('https://api1.com')).toBeInTheDocument() + expect(screen.getByText('https://api2.com')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx new file mode 100644 index 0000000000..702890bee9 --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx @@ -0,0 +1,112 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Hoisted mocks +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + refresh: vi.fn(), + setShowExternalKnowledgeAPIModal: vi.fn(), + mutateExternalKnowledgeApis: vi.fn(), + externalKnowledgeApiList: [] as Array<{ id: string, name: string, settings: { endpoint: string } }>, +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalKnowledgeAPIModal: mocks.setShowExternalKnowledgeAPIModal, + }), +})) + +vi.mock('@/context/external-knowledge-api-context', () => ({ + useExternalKnowledgeApi: () => ({ + externalKnowledgeApiList: mocks.externalKnowledgeApiList, + mutateExternalKnowledgeApis: mocks.mutateExternalKnowledgeApis, + }), +})) + +// Mock ExternalApiSelect as simple stub +type MockSelectItem = { value: string, name: string } +vi.mock('../ExternalApiSelect', () => ({ + default: ({ items, value, onSelect }: { items: MockSelectItem[], value?: string, onSelect: (item: MockSelectItem) => void }) => ( + <div data-testid="external-api-select"> + <span data-testid="select-value">{value}</span> + <span data-testid="select-items-count">{items.length}</span> + {items.map((item: MockSelectItem) => ( + <button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}> + {item.name} + </button> + ))} + </div> + ), +})) + +const { default: ExternalApiSelection } = await import('../ExternalApiSelection') + +describe('ExternalApiSelection', () => { + const defaultProps = { + external_knowledge_api_id: '', + external_knowledge_id: '', + onChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.externalKnowledgeApiList = [ + { id: 'api-1', name: 'API One', settings: { endpoint: 'https://api1.com' } }, + { id: 'api-2', name: 'API Two', settings: { endpoint: 'https://api2.com' } }, + ] + }) + + describe('rendering', () => { + it('should render API selection label', () => { + render(<ExternalApiSelection {...defaultProps} />) + expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument() + }) + + it('should render knowledge ID label and input', () => { + render(<ExternalApiSelection {...defaultProps} />) + expect(screen.getByText('dataset.externalKnowledgeId')).toBeInTheDocument() + }) + + it('should render ExternalApiSelect when APIs exist', () => { + render(<ExternalApiSelection {...defaultProps} />) + expect(screen.getByTestId('external-api-select')).toBeInTheDocument() + expect(screen.getByTestId('select-items-count').textContent).toBe('2') + }) + + it('should show add button when no APIs exist', () => { + mocks.externalKnowledgeApiList = [] + render(<ExternalApiSelection {...defaultProps} />) + expect(screen.getByText('dataset.noExternalKnowledge')).toBeInTheDocument() + }) + }) + + describe('interactions', () => { + it('should call onChange when API selected', () => { + render(<ExternalApiSelection {...defaultProps} />) + fireEvent.click(screen.getByTestId('select-api-2')) + expect(defaultProps.onChange).toHaveBeenCalledWith( + expect.objectContaining({ external_knowledge_api_id: 'api-2' }), + ) + }) + + it('should call onChange when knowledge ID input changes', () => { + render(<ExternalApiSelection {...defaultProps} />) + const input = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + fireEvent.change(input, { target: { value: 'kb-123' } }) + expect(defaultProps.onChange).toHaveBeenCalledWith( + expect.objectContaining({ external_knowledge_id: 'kb-123' }), + ) + }) + + it('should call setShowExternalKnowledgeAPIModal when add button clicked', () => { + mocks.externalKnowledgeApiList = [] + render(<ExternalApiSelection {...defaultProps} />) + fireEvent.click(screen.getByText('dataset.noExternalKnowledge')) + expect(mocks.setShowExternalKnowledgeAPIModal).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx new file mode 100644 index 0000000000..9965565111 --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import InfoPanel from '../InfoPanel' + +// Mock useDocLink from @/context/i18n +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, +})) + +describe('InfoPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies the panel renders all expected content + describe('Rendering', () => { + it('should render without crashing', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.title/)).toBeInTheDocument() + }) + + it('should render the title text', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.title/)).toBeInTheDocument() + }) + + it('should render the front content text', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.content\.front/)).toBeInTheDocument() + }) + + it('should render the content link', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.content\.link/)).toBeInTheDocument() + }) + + it('should render the end content text', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.content\.end/)).toBeInTheDocument() + }) + + it('should render the learn more link', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.learnMore/)).toBeInTheDocument() + }) + + it('should render the book icon', () => { + const { container } = render(<InfoPanel />) + const svgIcons = container.querySelectorAll('svg') + expect(svgIcons.length).toBeGreaterThanOrEqual(1) + }) + }) + + // Props: tests links and their attributes + describe('Links', () => { + it('should have correct href for external knowledge API doc link', () => { + render(<InfoPanel />) + const docLink = screen.getByText(/connectDatasetIntro\.content\.link/) + expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/knowledge/external-knowledge-api') + }) + + it('should have correct href for learn more link', () => { + render(<InfoPanel />) + const learnMoreLink = screen.getByText(/connectDatasetIntro\.learnMore/) + expect(learnMoreLink).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/knowledge/connect-external-knowledge-base') + }) + + it('should open links in new tab', () => { + render(<InfoPanel />) + const docLink = screen.getByText(/connectDatasetIntro\.content\.link/) + expect(docLink).toHaveAttribute('target', '_blank') + expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') + + const learnMoreLink = screen.getByText(/connectDatasetIntro\.learnMore/) + expect(learnMoreLink).toHaveAttribute('target', '_blank') + expect(learnMoreLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + // Styles: checks structural class names + describe('Styles', () => { + it('should have correct container width', () => { + const { container } = render(<InfoPanel />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('w-[360px]') + }) + + it('should have correct panel background', () => { + const { container } = render(<InfoPanel />) + const panel = container.querySelector('.bg-background-section') + expect(panel).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx new file mode 100644 index 0000000000..3e2698ccb6 --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx @@ -0,0 +1,153 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import KnowledgeBaseInfo from '../KnowledgeBaseInfo' + +describe('KnowledgeBaseInfo', () => { + const defaultProps = { + name: '', + description: '', + onChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies all form fields render + describe('Rendering', () => { + it('should render without crashing', () => { + render(<KnowledgeBaseInfo {...defaultProps} />) + expect(screen.getByText(/externalKnowledgeName/)).toBeInTheDocument() + }) + + it('should render the name label', () => { + render(<KnowledgeBaseInfo {...defaultProps} />) + expect(screen.getByText(/externalKnowledgeName(?!Placeholder)/)).toBeInTheDocument() + }) + + it('should render the description label', () => { + render(<KnowledgeBaseInfo {...defaultProps} />) + expect(screen.getByText(/externalKnowledgeDescription(?!Placeholder)/)).toBeInTheDocument() + }) + + it('should render name input with placeholder', () => { + render(<KnowledgeBaseInfo {...defaultProps} />) + const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/) + expect(input).toBeInTheDocument() + }) + + it('should render description textarea with placeholder', () => { + render(<KnowledgeBaseInfo {...defaultProps} />) + const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/) + expect(textarea).toBeInTheDocument() + }) + }) + + // Props: tests value display and onChange callbacks + describe('Props', () => { + it('should display name in the input', () => { + render(<KnowledgeBaseInfo {...defaultProps} name="My Knowledge Base" />) + const input = screen.getByDisplayValue('My Knowledge Base') + expect(input).toBeInTheDocument() + }) + + it('should display description in the textarea', () => { + render(<KnowledgeBaseInfo {...defaultProps} description="A description" />) + const textarea = screen.getByDisplayValue('A description') + expect(textarea).toBeInTheDocument() + }) + + it('should call onChange with name when name input changes', () => { + const onChange = vi.fn() + render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />) + const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/) + + fireEvent.change(input, { target: { value: 'New Name' } }) + + expect(onChange).toHaveBeenCalledWith({ name: 'New Name' }) + }) + + it('should call onChange with description when textarea changes', () => { + const onChange = vi.fn() + render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />) + const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/) + + fireEvent.change(textarea, { target: { value: 'New Description' } }) + + expect(onChange).toHaveBeenCalledWith({ description: 'New Description' }) + }) + }) + + // User Interactions: tests form interactions + describe('User Interactions', () => { + it('should allow typing in name input', () => { + const onChange = vi.fn() + render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />) + const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/) + + fireEvent.change(input, { target: { value: 'Typed Name' } }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith({ name: 'Typed Name' }) + }) + + it('should allow typing in description textarea', () => { + const onChange = vi.fn() + render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />) + const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/) + + fireEvent.change(textarea, { target: { value: 'Typed Desc' } }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith({ description: 'Typed Desc' }) + }) + }) + + // Edge Cases: tests boundary values + describe('Edge Cases', () => { + it('should handle empty name', () => { + render(<KnowledgeBaseInfo {...defaultProps} name="" />) + const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/) + expect(input).toHaveValue('') + }) + + it('should handle undefined description', () => { + render(<KnowledgeBaseInfo {...defaultProps} description={undefined} />) + const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/) + expect(textarea).toBeInTheDocument() + }) + + it('should handle very long name', () => { + const longName = 'K'.repeat(500) + render(<KnowledgeBaseInfo {...defaultProps} name={longName} />) + const input = screen.getByDisplayValue(longName) + expect(input).toBeInTheDocument() + }) + + it('should handle very long description', () => { + const longDesc = 'D'.repeat(2000) + render(<KnowledgeBaseInfo {...defaultProps} description={longDesc} />) + const textarea = screen.getByDisplayValue(longDesc) + expect(textarea).toBeInTheDocument() + }) + + it('should handle special characters in name', () => { + const specialName = 'Test & "quotes" <angle>' + render(<KnowledgeBaseInfo {...defaultProps} name={specialName} />) + const input = screen.getByDisplayValue(specialName) + expect(input).toBeInTheDocument() + }) + + it('should apply filled text color class when description has content', () => { + const { container } = render(<KnowledgeBaseInfo {...defaultProps} description="has content" />) + const textarea = container.querySelector('textarea') + expect(textarea).toHaveClass('text-components-input-text-filled') + }) + + it('should apply placeholder text color class when description is empty', () => { + const { container } = render(<KnowledgeBaseInfo {...defaultProps} description="" />) + const textarea = container.querySelector('textarea') + expect(textarea).toHaveClass('text-components-input-text-placeholder') + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx new file mode 100644 index 0000000000..e4da8a1a5a --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock param items to simplify testing +vi.mock('@/app/components/base/param-item/top-k-item', () => ({ + default: ({ value, onChange, enable }: { value: number, onChange: (key: string, val: number) => void, enable: boolean }) => ( + <div data-testid="top-k-item"> + <span data-testid="top-k-value">{value}</span> + <button data-testid="top-k-change" onClick={() => onChange('top_k', 8)}>change</button> + <span data-testid="top-k-enabled">{String(enable)}</span> + </div> + ), +})) + +vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({ + default: ({ value, onChange, enable, onSwitchChange }: { value: number, onChange: (key: string, val: number) => void, enable: boolean, onSwitchChange: (key: string, val: boolean) => void }) => ( + <div data-testid="score-threshold-item"> + <span data-testid="score-value">{value}</span> + <button data-testid="score-change" onClick={() => onChange('score_threshold', 0.9)}>change</button> + <span data-testid="score-enabled">{String(enable)}</span> + <button data-testid="score-switch" onClick={() => onSwitchChange('score_threshold_enabled', true)}>switch</button> + </div> + ), +})) + +const { default: RetrievalSettings } = await import('../RetrievalSettings') + +describe('RetrievalSettings', () => { + const defaultProps = { + topK: 3, + scoreThreshold: 0.5, + scoreThresholdEnabled: false, + onChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render TopKItem and ScoreThresholdItem', () => { + render(<RetrievalSettings {...defaultProps} />) + expect(screen.getByTestId('top-k-item')).toBeInTheDocument() + expect(screen.getByTestId('score-threshold-item')).toBeInTheDocument() + }) + + it('should pass topK value to TopKItem', () => { + render(<RetrievalSettings {...defaultProps} />) + expect(screen.getByTestId('top-k-value').textContent).toBe('3') + }) + + it('should pass scoreThreshold to ScoreThresholdItem', () => { + render(<RetrievalSettings {...defaultProps} />) + expect(screen.getByTestId('score-value').textContent).toBe('0.5') + }) + + it('should show label when not in hit testing and not in retrieval setting', () => { + render(<RetrievalSettings {...defaultProps} />) + expect(screen.getByText('dataset.retrievalSettings')).toBeInTheDocument() + }) + + it('should hide label when isInHitTesting is true', () => { + render(<RetrievalSettings {...defaultProps} isInHitTesting />) + expect(screen.queryByText('dataset.retrievalSettings')).not.toBeInTheDocument() + }) + + it('should hide label when isInRetrievalSetting is true', () => { + render(<RetrievalSettings {...defaultProps} isInRetrievalSetting />) + expect(screen.queryByText('dataset.retrievalSettings')).not.toBeInTheDocument() + }) + }) + + describe('user interactions', () => { + it('should call onChange with top_k when TopKItem changes', () => { + render(<RetrievalSettings {...defaultProps} />) + fireEvent.click(screen.getByTestId('top-k-change')) + expect(defaultProps.onChange).toHaveBeenCalledWith({ top_k: 8 }) + }) + + it('should call onChange with score_threshold when ScoreThresholdItem changes', () => { + render(<RetrievalSettings {...defaultProps} />) + fireEvent.click(screen.getByTestId('score-change')) + expect(defaultProps.onChange).toHaveBeenCalledWith({ score_threshold: 0.9 }) + }) + + it('should call onChange with score_threshold_enabled when switch changes', () => { + render(<RetrievalSettings {...defaultProps} />) + fireEvent.click(screen.getByTestId('score-switch')) + expect(defaultProps.onChange).toHaveBeenCalledWith({ score_threshold_enabled: true }) + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/external-knowledge-base/create/index.spec.tsx rename to web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx index d56833fd36..b8aa8b33d7 100644 --- a/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx @@ -2,10 +2,9 @@ import type { ExternalAPIItem } from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import ExternalKnowledgeBaseCreate from './index' -import RetrievalSettings from './RetrievalSettings' +import ExternalKnowledgeBaseCreate from '../index' +import RetrievalSettings from '../RetrievalSettings' -// Mock next/navigation const mockReplace = vi.fn() const mockRefresh = vi.fn() vi.mock('next/navigation', () => ({ @@ -438,7 +437,6 @@ describe('ExternalKnowledgeBaseCreate', () => { const onConnect = vi.fn() renderComponent({ onConnect }) - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) @@ -484,7 +482,6 @@ describe('ExternalKnowledgeBaseCreate', () => { mockExternalKnowledgeApiList = [] renderComponent() - // Click the add button const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') await user.click(addButton!) @@ -503,7 +500,6 @@ describe('ExternalKnowledgeBaseCreate', () => { mockExternalKnowledgeApiList = [] renderComponent() - // Click the add button const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') await user.click(addButton!) @@ -521,7 +517,6 @@ describe('ExternalKnowledgeBaseCreate', () => { mockExternalKnowledgeApiList = [] renderComponent() - // Click the add button const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') await user.click(addButton!) @@ -536,7 +531,6 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) @@ -549,7 +543,6 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) @@ -561,11 +554,9 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) - // Click on create new API option const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') await user.click(createNewApiOption) @@ -582,11 +573,9 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) - // Click on create new API option const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') await user.click(createNewApiOption) @@ -602,11 +591,9 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) - // Click on create new API option const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') await user.click(createNewApiOption) @@ -621,7 +608,6 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) @@ -640,12 +626,10 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click to open const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) expect(screen.getByText('https://api1.example.com')).toBeInTheDocument() - // Click again to close await user.click(apiSelector) expect(screen.queryByText('https://api1.example.com')).not.toBeInTheDocument() }) diff --git a/web/app/components/datasets/extra-info/index.spec.tsx b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/datasets/extra-info/index.spec.tsx rename to web/app/components/datasets/extra-info/__tests__/index.spec.tsx index ce34ea26e3..f4e651d3c5 100644 --- a/web/app/components/datasets/extra-info/index.spec.tsx +++ b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx @@ -4,20 +4,15 @@ import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' -// ============================================================================ // Component Imports (after mocks) -// ============================================================================ -import ApiAccess from './api-access' -import ApiAccessCard from './api-access/card' -import ExtraInfo from './index' -import Statistics from './statistics' +import ApiAccess from '../api-access' +import ApiAccessCard from '../api-access/card' +import ExtraInfo from '../index' +import Statistics from '../statistics' -// ============================================================================ // Mock Setup -// ============================================================================ -// Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -69,7 +64,6 @@ vi.mock('@/context/app-context', () => ({ ), })) -// Mock service hooks const mockEnableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' })) const mockDisableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' })) @@ -111,9 +105,7 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ ), })) -// ============================================================================ // Test Data Factory -// ============================================================================ const createMockRelatedApp = (overrides: Partial<RelatedApp> = {}): RelatedApp => ({ id: 'app-1', @@ -132,9 +124,7 @@ const createMockRelatedAppsResponse = (count: number = 2): RelatedAppResponse => total: count, }) -// ============================================================================ // Statistics Component Tests -// ============================================================================ describe('Statistics', () => { beforeEach(() => { @@ -372,9 +362,7 @@ describe('Statistics', () => { }) }) -// ============================================================================ // ApiAccess Component Tests -// ============================================================================ describe('ApiAccess', () => { beforeEach(() => { @@ -528,9 +516,7 @@ describe('ApiAccess', () => { }) }) -// ============================================================================ // ApiAccessCard Component Tests -// ============================================================================ describe('ApiAccessCard', () => { beforeEach(() => { @@ -745,9 +731,7 @@ describe('ApiAccessCard', () => { }) }) -// ============================================================================ // ExtraInfo (Main Component) Tests -// ============================================================================ describe('ExtraInfo', () => { beforeEach(() => { @@ -1101,10 +1085,6 @@ describe('ExtraInfo', () => { }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('ExtraInfo Integration', () => { beforeEach(() => { vi.clearAllMocks() @@ -1142,7 +1122,6 @@ describe('ExtraInfo Integration', () => { expect(screen.getByText('10')).toBeInTheDocument() expect(screen.getByText('3')).toBeInTheDocument() - // Click on ApiAccess to open the card const apiAccessTrigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') if (apiAccessTrigger) await user.click(apiAccessTrigger) diff --git a/web/app/components/datasets/extra-info/statistics.spec.tsx b/web/app/components/datasets/extra-info/__tests__/statistics.spec.tsx similarity index 88% rename from web/app/components/datasets/extra-info/statistics.spec.tsx rename to web/app/components/datasets/extra-info/__tests__/statistics.spec.tsx index d7f79a1ab2..5cc6a9b1d5 100644 --- a/web/app/components/datasets/extra-info/statistics.spec.tsx +++ b/web/app/components/datasets/extra-info/__tests__/statistics.spec.tsx @@ -2,14 +2,7 @@ import type { RelatedApp, RelatedAppResponse } from '@/models/datasets' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' -import Statistics from './statistics' - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import Statistics from '../statistics' // Mock useDocLink vi.mock('@/context/i18n', () => ({ @@ -43,7 +36,7 @@ describe('Statistics', () => { it('should render document label', () => { render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />) - expect(screen.getByText('datasetMenus.documents')).toBeInTheDocument() + expect(screen.getByText('common.datasetMenus.documents')).toBeInTheDocument() }) it('should render related apps total', () => { @@ -53,7 +46,7 @@ describe('Statistics', () => { it('should render related app label', () => { render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />) - expect(screen.getByText('datasetMenus.relatedApp')).toBeInTheDocument() + expect(screen.getByText('common.datasetMenus.relatedApp')).toBeInTheDocument() }) it('should render -- for undefined document count', () => { diff --git a/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx b/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx new file mode 100644 index 0000000000..3fa542f002 --- /dev/null +++ b/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx @@ -0,0 +1,186 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Card from '../card' + +// Shared mock state for context selectors +let mockDatasetId: string | undefined = 'dataset-123' +let mockMutateDatasetRes: ReturnType<typeof vi.fn> = vi.fn() +let mockIsCurrentWorkspaceManager = true + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + dataset: { id: mockDatasetId }, + mutateDatasetRes: mockMutateDatasetRes, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: Record<string, unknown>) => unknown) => + selector({ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager }), +})) + +const mockEnableApi = vi.fn() +const mockDisableApi = vi.fn() + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useEnableDatasetServiceApi: () => ({ + mutateAsync: mockEnableApi, + }), + useDisableDatasetServiceApi: () => ({ + mutateAsync: mockDisableApi, + }), +})) + +vi.mock('@/hooks/use-api-access-url', () => ({ + useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets', +})) + +describe('Card (API Access)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDatasetId = 'dataset-123' + mockMutateDatasetRes = vi.fn() + mockIsCurrentWorkspaceManager = true + }) + + // Rendering: verifies enabled/disabled states render correctly + describe('Rendering', () => { + it('should render without crashing when api is enabled', () => { + render(<Card apiEnabled={true} />) + expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument() + }) + + it('should render without crashing when api is disabled', () => { + render(<Card apiEnabled={false} />) + expect(screen.getByText(/serviceApi\.disabled/)).toBeInTheDocument() + }) + + it('should render API access tip text', () => { + render(<Card apiEnabled={true} />) + expect(screen.getByText(/appMenus\.apiAccessTip/)).toBeInTheDocument() + }) + + it('should render API reference link', () => { + render(<Card apiEnabled={true} />) + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') + }) + + it('should render API doc text in link', () => { + render(<Card apiEnabled={true} />) + expect(screen.getByText(/apiInfo\.doc/)).toBeInTheDocument() + }) + + it('should open API reference link in new tab', () => { + render(<Card apiEnabled={true} />) + const link = screen.getByRole('link') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + // Props: tests enabled/disabled visual states + describe('Props', () => { + it('should show green indicator text when enabled', () => { + render(<Card apiEnabled={true} />) + const enabledText = screen.getByText(/serviceApi\.enabled/) + expect(enabledText).toHaveClass('text-text-success') + }) + + it('should show warning text when disabled', () => { + render(<Card apiEnabled={false} />) + const disabledText = screen.getByText(/serviceApi\.disabled/) + expect(disabledText).toHaveClass('text-text-warning') + }) + }) + + // User Interactions: tests toggle behavior + describe('User Interactions', () => { + it('should call enableDatasetServiceApi when toggling on', async () => { + mockEnableApi.mockResolvedValue({ result: 'success' }) + render(<Card apiEnabled={false} />) + + const switchButton = screen.getByRole('switch') + fireEvent.click(switchButton) + + await waitFor(() => { + expect(mockEnableApi).toHaveBeenCalledWith('dataset-123') + }) + }) + + it('should call disableDatasetServiceApi when toggling off', async () => { + mockDisableApi.mockResolvedValue({ result: 'success' }) + render(<Card apiEnabled={true} />) + + const switchButton = screen.getByRole('switch') + fireEvent.click(switchButton) + + await waitFor(() => { + expect(mockDisableApi).toHaveBeenCalledWith('dataset-123') + }) + }) + + it('should call mutateDatasetRes on successful toggle', async () => { + mockEnableApi.mockResolvedValue({ result: 'success' }) + render(<Card apiEnabled={false} />) + + const switchButton = screen.getByRole('switch') + fireEvent.click(switchButton) + + await waitFor(() => { + expect(mockMutateDatasetRes).toHaveBeenCalled() + }) + }) + + it('should not call mutateDatasetRes when result is not success', async () => { + mockEnableApi.mockResolvedValue({ result: 'fail' }) + render(<Card apiEnabled={false} />) + + const switchButton = screen.getByRole('switch') + fireEvent.click(switchButton) + + await waitFor(() => { + expect(mockEnableApi).toHaveBeenCalled() + }) + expect(mockMutateDatasetRes).not.toHaveBeenCalled() + }) + }) + + // Switch disabled state + describe('Switch State', () => { + it('should disable switch when user is not workspace manager', () => { + mockIsCurrentWorkspaceManager = false + render(<Card apiEnabled={true} />) + + const switchButton = screen.getByRole('switch') + expect(switchButton).toHaveAttribute('aria-checked', 'true') + // Headless UI Switch uses CSS classes for disabled state, not the disabled attribute + expect(switchButton).toHaveClass('!cursor-not-allowed', '!opacity-50') + }) + + it('should enable switch when user is workspace manager', () => { + mockIsCurrentWorkspaceManager = true + render(<Card apiEnabled={true} />) + + const switchButton = screen.getByRole('switch') + expect(switchButton).not.toBeDisabled() + }) + }) + + // Edge Cases: tests boundary scenarios + describe('Edge Cases', () => { + it('should handle undefined dataset id', async () => { + mockDatasetId = undefined + mockEnableApi.mockResolvedValue({ result: 'success' }) + render(<Card apiEnabled={false} />) + + const switchButton = screen.getByRole('switch') + fireEvent.click(switchButton) + + await waitFor(() => { + expect(mockEnableApi).toHaveBeenCalledWith('') + }) + }) + }) +}) diff --git a/web/app/components/datasets/extra-info/api-access/index.spec.tsx b/web/app/components/datasets/extra-info/api-access/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/extra-info/api-access/index.spec.tsx rename to web/app/components/datasets/extra-info/api-access/__tests__/index.spec.tsx index 19e6b1ebca..ff866921f2 100644 --- a/web/app/components/datasets/extra-info/api-access/index.spec.tsx +++ b/web/app/components/datasets/extra-info/api-access/__tests__/index.spec.tsx @@ -1,13 +1,6 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import ApiAccess from './index' - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import ApiAccess from '../index' // Mock context and hooks for Card component vi.mock('@/context/dataset-detail', () => ({ @@ -34,27 +27,27 @@ afterEach(() => { describe('ApiAccess', () => { it('should render without crashing', () => { render(<ApiAccess expand={true} apiEnabled={true} />) - expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument() + expect(screen.getByText('common.appMenus.apiAccess')).toBeInTheDocument() }) it('should render API access text when expanded', () => { render(<ApiAccess expand={true} apiEnabled={true} />) - expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument() + expect(screen.getByText('common.appMenus.apiAccess')).toBeInTheDocument() }) it('should not render API access text when collapsed', () => { render(<ApiAccess expand={false} apiEnabled={true} />) - expect(screen.queryByText('appMenus.apiAccess')).not.toBeInTheDocument() + expect(screen.queryByText('common.appMenus.apiAccess')).not.toBeInTheDocument() }) it('should render with apiEnabled=true', () => { render(<ApiAccess expand={true} apiEnabled={true} />) - expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument() + expect(screen.getByText('common.appMenus.apiAccess')).toBeInTheDocument() }) it('should render with apiEnabled=false', () => { render(<ApiAccess expand={true} apiEnabled={false} />) - expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument() + expect(screen.getByText('common.appMenus.apiAccess')).toBeInTheDocument() }) it('should be wrapped with React.memo', () => { @@ -67,7 +60,6 @@ describe('ApiAccess', () => { const trigger = container.querySelector('.cursor-pointer') expect(trigger).toBeInTheDocument() - // Click to open await act(async () => { fireEvent.click(trigger!) }) diff --git a/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx new file mode 100644 index 0000000000..1f3907bffc --- /dev/null +++ b/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx @@ -0,0 +1,168 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Card from '../card' + +vi.mock('@/hooks/use-api-access-url', () => ({ + useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets', +})) + +vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ + default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => + isShow ? <div data-testid="secret-key-modal"><button onClick={onClose}>close</button></div> : null, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ) +} + +const renderWithProviders = (ui: React.ReactElement) => { + return render(ui, { wrapper: createWrapper() }) +} + +describe('Card (Service API)', () => { + const defaultProps = { + apiBaseUrl: 'https://api.dify.ai/v1', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies all key elements render + describe('Rendering', () => { + it('should render without crashing', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.card\.title/)).toBeInTheDocument() + }) + + it('should render card title', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.card\.title/)).toBeInTheDocument() + }) + + it('should render enabled status', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument() + }) + + it('should render endpoint label', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.card\.endpoint/)).toBeInTheDocument() + }) + + it('should render the API base URL', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument() + }) + + it('should render API key button', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.card\.apiKey/)).toBeInTheDocument() + }) + + it('should render API reference button', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.card\.apiReference/)).toBeInTheDocument() + }) + }) + + // Props: tests different apiBaseUrl values + describe('Props', () => { + it('should display provided apiBaseUrl', () => { + renderWithProviders(<Card apiBaseUrl="https://custom-api.example.com" />) + expect(screen.getByText('https://custom-api.example.com')).toBeInTheDocument() + }) + + it('should show green indicator when apiBaseUrl is provided', () => { + renderWithProviders(<Card apiBaseUrl="https://api.dify.ai" />) + // The Indicator component receives color="green" when apiBaseUrl is truthy + const statusText = screen.getByText(/serviceApi\.enabled/) + expect(statusText).toHaveClass('text-text-success') + }) + + it('should show yellow indicator when apiBaseUrl is empty', () => { + renderWithProviders(<Card apiBaseUrl="" />) + // Still shows "enabled" text but indicator color differs + expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument() + }) + }) + + // User Interactions: tests button clicks and modal + describe('User Interactions', () => { + it('should open secret key modal when API key button is clicked', () => { + renderWithProviders(<Card {...defaultProps} />) + + // Modal should not be visible before clicking + expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + + const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button') + fireEvent.click(apiKeyButton!) + + // Modal should appear after clicking + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + }) + + it('should close secret key modal when onClose is called', () => { + renderWithProviders(<Card {...defaultProps} />) + + const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button') + fireEvent.click(apiKeyButton!) + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByText('close')) + expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + }) + + it('should render API reference as a link', () => { + renderWithProviders(<Card {...defaultProps} />) + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + // Styles: verifies container structure + describe('Styles', () => { + it('should have correct container width', () => { + const { container } = renderWithProviders(<Card {...defaultProps} />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('w-[360px]') + }) + + it('should have rounded corners', () => { + const { container } = renderWithProviders(<Card {...defaultProps} />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('rounded-xl') + }) + }) + + // Edge Cases: tests empty/long URLs + describe('Edge Cases', () => { + it('should handle empty apiBaseUrl', () => { + renderWithProviders(<Card apiBaseUrl="" />) + // Should still render the structure + expect(screen.getByText(/serviceApi\.card\.endpoint/)).toBeInTheDocument() + }) + + it('should handle very long apiBaseUrl', () => { + const longUrl = `https://api.dify.ai/${'path/'.repeat(50)}` + renderWithProviders(<Card apiBaseUrl={longUrl} />) + expect(screen.getByText(longUrl)).toBeInTheDocument() + }) + + it('should handle apiBaseUrl with special characters', () => { + const specialUrl = 'https://api.dify.ai/v1?key=value&foo=bar' + renderWithProviders(<Card apiBaseUrl={specialUrl} />) + expect(screen.getByText(specialUrl)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/extra-info/service-api/index.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/extra-info/service-api/index.spec.tsx rename to web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx index cf912b787f..b94508de6a 100644 --- a/web/app/components/datasets/extra-info/service-api/index.spec.tsx +++ b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx @@ -2,18 +2,13 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -// ============================================================================ // Component Imports (after mocks) -// ============================================================================ -import Card from './card' -import ServiceApi from './index' +import Card from '../card' +import ServiceApi from '../index' -// ============================================================================ // Mock Setup -// ============================================================================ -// Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -48,18 +43,13 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ ), })) -// ============================================================================ // ServiceApi Component Tests -// ============================================================================ describe('ServiceApi', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render(<ServiceApi apiBaseUrl="https://api.example.com" />) @@ -90,9 +80,7 @@ describe('ServiceApi', () => { }) }) - // -------------------------------------------------------------------------- // Props Variations Tests - // -------------------------------------------------------------------------- describe('Props Variations', () => { it('should show green Indicator when apiBaseUrl is provided', () => { const { container } = render(<ServiceApi apiBaseUrl="https://api.example.com" />) @@ -121,9 +109,6 @@ describe('ServiceApi', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should toggle popup open state on click', async () => { const user = userEvent.setup() @@ -188,9 +173,7 @@ describe('ServiceApi', () => { }) }) - // -------------------------------------------------------------------------- // Portal and Card Integration Tests - // -------------------------------------------------------------------------- describe('Portal and Card Integration', () => { it('should render Card component inside portal when open', async () => { const user = userEvent.setup() @@ -235,9 +218,6 @@ describe('ServiceApi', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle rapid toggle clicks gracefully', async () => { const user = userEvent.setup() @@ -279,9 +259,6 @@ describe('ServiceApi', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />) @@ -310,18 +287,13 @@ describe('ServiceApi', () => { }) }) -// ============================================================================ // Card Component Tests -// ============================================================================ describe('Card (service-api)', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render(<Card apiBaseUrl="https://api.example.com" />) @@ -380,9 +352,7 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- // Props Variations Tests - // -------------------------------------------------------------------------- describe('Props Variations', () => { it('should show green Indicator when apiBaseUrl is provided', () => { const { container } = render(<Card apiBaseUrl="https://api.example.com" />) @@ -423,9 +393,6 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should open SecretKeyModal when API Key button is clicked', async () => { const user = userEvent.setup() @@ -448,7 +415,6 @@ describe('Card (service-api)', () => { render(<Card apiBaseUrl="https://api.example.com" />) - // Open modal const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') if (apiKeyButton) await user.click(apiKeyButton) @@ -457,7 +423,6 @@ describe('Card (service-api)', () => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() }) - // Close modal const closeButton = screen.getByTestId('close-modal-btn') await user.click(closeButton) @@ -489,7 +454,6 @@ describe('Card (service-api)', () => { // Initially modal should not be visible expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - // Open modal const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') if (apiKeyButton) await user.click(apiKeyButton) @@ -499,7 +463,6 @@ describe('Card (service-api)', () => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() }) - // Close modal const closeButton = screen.getByTestId('close-modal-btn') await user.click(closeButton) @@ -510,9 +473,7 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- // Modal State Tests - // -------------------------------------------------------------------------- describe('Modal State', () => { it('should initialize with modal closed', () => { render(<Card apiBaseUrl="https://api.example.com" />) @@ -547,7 +508,6 @@ describe('Card (service-api)', () => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() }) - // Close modal const closeButton = screen.getByTestId('close-modal-btn') await user.click(closeButton) @@ -587,9 +547,6 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty apiBaseUrl gracefully', () => { render(<Card apiBaseUrl="" />) @@ -614,12 +571,10 @@ describe('Card (service-api)', () => { render(<Card apiBaseUrl="https://api.example.com" />) - // Click API Key button const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') if (apiKeyButton) await user.click(apiKeyButton) - // Close modal await waitFor(() => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() }) @@ -635,9 +590,6 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render(<Card apiBaseUrl="https://api.example.com" />) @@ -667,9 +619,7 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- // Copy Functionality Tests - // -------------------------------------------------------------------------- describe('Copy Functionality', () => { it('should render CopyFeedback component for apiBaseUrl', () => { const { container } = render(<Card apiBaseUrl="https://api.example.com" />) @@ -686,10 +636,6 @@ describe('Card (service-api)', () => { }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('ServiceApi Integration', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/datasets/formatted-text/__tests__/formatted.spec.tsx b/web/app/components/datasets/formatted-text/__tests__/formatted.spec.tsx new file mode 100644 index 0000000000..2f7fe684ed --- /dev/null +++ b/web/app/components/datasets/formatted-text/__tests__/formatted.spec.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { FormattedText } from '../formatted' + +describe('FormattedText', () => { + it('should render children', () => { + render(<FormattedText>Hello World</FormattedText>) + expect(screen.getByText('Hello World')).toBeInTheDocument() + }) + + it('should apply leading-7 class by default', () => { + render(<FormattedText>Text</FormattedText>) + expect(screen.getByText('Text')).toHaveClass('leading-7') + }) + + it('should merge custom className', () => { + render(<FormattedText className="custom-class">Text</FormattedText>) + const el = screen.getByText('Text') + expect(el).toHaveClass('leading-7') + expect(el).toHaveClass('custom-class') + }) + + it('should render as a p element', () => { + render(<FormattedText>Text</FormattedText>) + expect(screen.getByText('Text').tagName).toBe('P') + }) +}) diff --git a/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx b/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx new file mode 100644 index 0000000000..13f7b4862d --- /dev/null +++ b/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx @@ -0,0 +1,190 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Capture the onOpenChange callback to simulate hover interactions +let capturedOnOpenChange: ((open: boolean) => void) | null = null + +vi.mock('@floating-ui/react', () => ({ + autoUpdate: vi.fn(), + flip: vi.fn(), + shift: vi.fn(), + offset: vi.fn(), + FloatingFocusManager: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="floating-focus-manager"> + {children} + </div> + ), + useFloating: ({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) => { + capturedOnOpenChange = onOpenChange ?? null + return { + refs: { setReference: vi.fn(), setFloating: vi.fn() }, + floatingStyles: {}, + context: { open: false, onOpenChange: vi.fn(), refs: { domReference: { current: null } }, nodeId: undefined }, + } + }, + useHover: () => ({}), + useDismiss: () => ({}), + useRole: () => ({}), + useInteractions: () => ({ + getReferenceProps: () => ({}), + getFloatingProps: () => ({}), + }), +})) + +vi.mock('@/app/components/base/action-button', () => { + const comp = ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( + <button data-testid="action-button" onClick={onClick}>{children}</button> + ) + return { + default: comp, + ActionButtonState: { Destructive: 'destructive' }, + } +}) + +const { EditSlice } = await import('../edit-slice') + +// Helper to find divider span (zero-width space) +const findDividerSpan = (container: HTMLElement) => + Array.from(container.querySelectorAll('span')).find(s => s.textContent?.includes('\u200B')) + +describe('EditSlice', () => { + const defaultProps = { + label: 'S1', + text: 'Sample text content', + onDelete: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + capturedOnOpenChange = null + }) + + // ---- Rendering Tests ---- + it('should render label and text', () => { + render(<EditSlice {...defaultProps} />) + expect(screen.getByText('S1')).toBeInTheDocument() + expect(screen.getByText('Sample text content')).toBeInTheDocument() + }) + + it('should render divider by default', () => { + const { container } = render(<EditSlice {...defaultProps} />) + expect(findDividerSpan(container)).toBeTruthy() + }) + + it('should not render divider when showDivider is false', () => { + const { container } = render(<EditSlice {...defaultProps} showDivider={false} />) + expect(findDividerSpan(container)).toBeFalsy() + }) + + // ---- Class Name Tests ---- + it('should apply custom labelClassName', () => { + render(<EditSlice {...defaultProps} labelClassName="label-extra" />) + const labelEl = screen.getByText('S1').parentElement + expect(labelEl).toHaveClass('label-extra') + }) + + it('should apply custom contentClassName', () => { + render(<EditSlice {...defaultProps} contentClassName="content-extra" />) + expect(screen.getByText('Sample text content')).toHaveClass('content-extra') + }) + + it('should apply labelInnerClassName to SliceLabel inner span', () => { + render(<EditSlice {...defaultProps} labelInnerClassName="inner-label" />) + expect(screen.getByText('S1')).toHaveClass('inner-label') + }) + + it('should apply custom className to wrapper', () => { + render(<EditSlice {...defaultProps} data-testid="edit-slice" className="custom-slice" />) + expect(screen.getByTestId('edit-slice')).toHaveClass('custom-slice') + }) + + it('should pass rest props to wrapper', () => { + render(<EditSlice {...defaultProps} data-testid="edit-slice" />) + expect(screen.getByTestId('edit-slice')).toBeInTheDocument() + }) + + // ---- Floating UI / Delete Button Tests ---- + it('should not show delete button when floating is closed', () => { + render(<EditSlice {...defaultProps} />) + expect(screen.queryByTestId('floating-focus-manager')).not.toBeInTheDocument() + }) + + it('should show delete button when onOpenChange triggers open', () => { + render(<EditSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + expect(screen.getByTestId('floating-focus-manager')).toBeInTheDocument() + }) + + it('should call onDelete when delete button is clicked', () => { + const onDelete = vi.fn() + render(<EditSlice {...defaultProps} onDelete={onDelete} />) + act(() => { + capturedOnOpenChange?.(true) + }) + fireEvent.click(screen.getByRole('button')) + expect(onDelete).toHaveBeenCalledTimes(1) + }) + + it('should close floating after delete button is clicked', () => { + render(<EditSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + expect(screen.getByTestId('floating-focus-manager')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button')) + expect(screen.queryByTestId('floating-focus-manager')).not.toBeInTheDocument() + }) + + it('should stop event propagation on delete click', () => { + const parentClick = vi.fn() + render( + <div onClick={parentClick}> + <EditSlice {...defaultProps} /> + </div>, + ) + act(() => { + capturedOnOpenChange?.(true) + }) + fireEvent.click(screen.getByRole('button')) + expect(parentClick).not.toHaveBeenCalled() + }) + + // ---- Destructive Hover Style Tests ---- + it('should apply destructive styles when hovering on delete button container', async () => { + render(<EditSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + const floatingSpan = screen.getByTestId('floating-focus-manager').firstElementChild as HTMLElement + fireEvent.mouseEnter(floatingSpan) + + await waitFor(() => { + const labelEl = screen.getByText('S1').parentElement + expect(labelEl).toHaveClass('!bg-state-destructive-solid') + expect(labelEl).toHaveClass('!text-text-primary-on-surface') + }) + expect(screen.getByText('Sample text content')).toHaveClass('!bg-state-destructive-hover-alt') + }) + + it('should remove destructive styles when mouse leaves delete button container', async () => { + render(<EditSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + const floatingSpan = screen.getByTestId('floating-focus-manager').firstElementChild as HTMLElement + fireEvent.mouseEnter(floatingSpan) + + await waitFor(() => { + expect(screen.getByText('S1').parentElement).toHaveClass('!bg-state-destructive-solid') + }) + + fireEvent.mouseLeave(floatingSpan) + + await waitFor(() => { + expect(screen.getByText('S1').parentElement).not.toHaveClass('!bg-state-destructive-solid') + expect(screen.getByText('Sample text content')).not.toHaveClass('!bg-state-destructive-hover-alt') + }) + }) +}) diff --git a/web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx b/web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx new file mode 100644 index 0000000000..88a5ee72e5 --- /dev/null +++ b/web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx @@ -0,0 +1,113 @@ +import { act, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Capture the onOpenChange callback to simulate hover interactions +let capturedOnOpenChange: ((open: boolean) => void) | null = null + +vi.mock('@floating-ui/react', () => ({ + autoUpdate: vi.fn(), + flip: vi.fn(), + shift: vi.fn(), + inline: vi.fn(), + useFloating: ({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) => { + capturedOnOpenChange = onOpenChange ?? null + return { + refs: { setReference: vi.fn(), setFloating: vi.fn() }, + floatingStyles: {}, + context: { open: false, onOpenChange: vi.fn(), refs: { domReference: { current: null } }, nodeId: undefined }, + } + }, + useHover: () => ({}), + useDismiss: () => ({}), + useRole: () => ({}), + useInteractions: () => ({ + getReferenceProps: () => ({}), + getFloatingProps: () => ({}), + }), +})) + +const { PreviewSlice } = await import('../preview-slice') + +// Helper to find divider span (zero-width space) +const findDividerSpan = (container: HTMLElement) => + Array.from(container.querySelectorAll('span')).find(s => s.textContent?.includes('\u200B')) + +describe('PreviewSlice', () => { + const defaultProps = { + label: 'P1', + text: 'Preview text', + tooltip: 'Tooltip content', + } + + beforeEach(() => { + vi.clearAllMocks() + capturedOnOpenChange = null + }) + + // ---- Rendering Tests ---- + it('should render label and text', () => { + render(<PreviewSlice {...defaultProps} />) + expect(screen.getByText('P1')).toBeInTheDocument() + expect(screen.getByText('Preview text')).toBeInTheDocument() + }) + + it('should not show tooltip by default', () => { + render(<PreviewSlice {...defaultProps} />) + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() + }) + + it('should always render a divider', () => { + const { container } = render(<PreviewSlice {...defaultProps} />) + expect(findDividerSpan(container)).toBeTruthy() + }) + + // ---- Class Name Tests ---- + it('should apply custom className', () => { + render(<PreviewSlice {...defaultProps} data-testid="preview-slice" className="preview-custom" />) + expect(screen.getByTestId('preview-slice')).toHaveClass('preview-custom') + }) + + it('should apply labelInnerClassName to the label inner span', () => { + render(<PreviewSlice {...defaultProps} labelInnerClassName="label-inner" />) + expect(screen.getByText('P1')).toHaveClass('label-inner') + }) + + it('should pass rest props to wrapper', () => { + render(<PreviewSlice {...defaultProps} data-testid="preview-slice" />) + expect(screen.getByTestId('preview-slice')).toBeInTheDocument() + }) + + // ---- Tooltip Interaction Tests ---- + it('should show tooltip when onOpenChange triggers open', () => { + render(<PreviewSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + expect(screen.getByText('Tooltip content')).toBeInTheDocument() + }) + + it('should hide tooltip when onOpenChange triggers close', () => { + render(<PreviewSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + expect(screen.getByText('Tooltip content')).toBeInTheDocument() + act(() => { + capturedOnOpenChange?.(false) + }) + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() + }) + + it('should render ReactNode tooltip content when open', () => { + render(<PreviewSlice {...defaultProps} tooltip={<strong>Rich tooltip</strong>} />) + act(() => { + capturedOnOpenChange?.(true) + }) + expect(screen.getByText('Rich tooltip')).toBeInTheDocument() + }) + + it('should render ReactNode label', () => { + render(<PreviewSlice {...defaultProps} label={<em>Emphasis</em>} />) + expect(screen.getByText('Emphasis')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/formatted-text/flavours/__tests__/shared.spec.tsx b/web/app/components/datasets/formatted-text/flavours/__tests__/shared.spec.tsx new file mode 100644 index 0000000000..036c661e80 --- /dev/null +++ b/web/app/components/datasets/formatted-text/flavours/__tests__/shared.spec.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { SliceContainer, SliceContent, SliceDivider, SliceLabel } from '../shared' + +describe('SliceContainer', () => { + it('should render children', () => { + render(<SliceContainer>content</SliceContainer>) + expect(screen.getByText('content')).toBeInTheDocument() + }) + + it('should be a span element', () => { + render(<SliceContainer>text</SliceContainer>) + expect(screen.getByText('text').tagName).toBe('SPAN') + }) + + it('should merge custom className', () => { + render(<SliceContainer className="custom">text</SliceContainer>) + expect(screen.getByText('text')).toHaveClass('custom') + }) + + it('should have display name', () => { + expect(SliceContainer.displayName).toBe('SliceContainer') + }) +}) + +describe('SliceLabel', () => { + it('should render children with uppercase text', () => { + render(<SliceLabel>Label</SliceLabel>) + expect(screen.getByText('Label')).toBeInTheDocument() + }) + + it('should apply label styling', () => { + render(<SliceLabel>Label</SliceLabel>) + const outer = screen.getByText('Label').parentElement! + expect(outer).toHaveClass('uppercase') + }) + + it('should apply labelInnerClassName to inner span', () => { + render(<SliceLabel labelInnerClassName="inner-class">Label</SliceLabel>) + expect(screen.getByText('Label')).toHaveClass('inner-class') + }) + + it('should have display name', () => { + expect(SliceLabel.displayName).toBe('SliceLabel') + }) +}) + +describe('SliceContent', () => { + it('should render children', () => { + render(<SliceContent>Content</SliceContent>) + expect(screen.getByText('Content')).toBeInTheDocument() + }) + + it('should apply whitespace-pre-line and break-all', () => { + render(<SliceContent>Content</SliceContent>) + const el = screen.getByText('Content') + expect(el).toHaveClass('whitespace-pre-line') + expect(el).toHaveClass('break-all') + }) + + it('should have display name', () => { + expect(SliceContent.displayName).toBe('SliceContent') + }) +}) + +describe('SliceDivider', () => { + it('should render as span', () => { + const { container } = render(<SliceDivider />) + expect(container.querySelector('span')).toBeInTheDocument() + }) + + it('should contain zero-width space', () => { + const { container } = render(<SliceDivider />) + expect(container.textContent).toContain('\u200B') + }) + + it('should merge custom className', () => { + const { container } = render(<SliceDivider className="custom" />) + expect(container.querySelector('span')).toHaveClass('custom') + }) + + it('should have display name', () => { + expect(SliceDivider.displayName).toBe('SliceDivider') + }) +}) diff --git a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx new file mode 100644 index 0000000000..0a5a55b744 --- /dev/null +++ b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx @@ -0,0 +1,1067 @@ +import type { ReactNode } from 'react' +import type { DataSet, HitTesting, HitTestingRecord, HitTestingResponse } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { RETRIEVE_METHOD } from '@/types/app' +import HitTestingPage from '../index' + +// Note: These components use real implementations for integration testing: +// - Toast, FloatRightContainer, Drawer, Pagination, Loading +// - RetrievalMethodConfig, EconomicalRetrievalMethodConfig +// - ImageUploaderInRetrievalTesting, retrieval-method-info, check-rerank-model + +// Mock RetrievalSettings to allow triggering onChange +vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSettings', () => ({ + default: ({ onChange }: { onChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void }) => { + return ( + <div data-testid="retrieval-settings-mock"> + <button data-testid="change-top-k" onClick={() => onChange({ top_k: 8 })}>Change Top K</button> + <button data-testid="change-score-threshold" onClick={() => onChange({ score_threshold: 0.9 })}>Change Score Threshold</button> + <button data-testid="change-score-enabled" onClick={() => onChange({ score_threshold_enabled: true })}>Change Score Enabled</button> + </div> + ) + }, +})) + +// Mock Setup + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + }), + usePathname: () => '/test', + useSearchParams: () => new URLSearchParams(), +})) + +// Mock use-context-selector +const mockDataset = { + id: 'dataset-1', + name: 'Test Dataset', + provider: 'vendor', + indexing_technique: 'high_quality' as const, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 10, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + is_multimodal: false, +} as Partial<DataSet> + +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(() => ({ dataset: mockDataset })), + useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })), + createContext: vi.fn(() => ({})), +})) + +// Mock dataset detail context +vi.mock('@/context/dataset-detail', () => ({ + default: {}, + useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset })), + useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset }) => unknown) => + selector({ dataset: mockDataset as DataSet }), + ), +})) + +const mockRecordsRefetch = vi.fn() +const mockHitTestingMutateAsync = vi.fn() +const mockExternalHitTestingMutateAsync = vi.fn() + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetTestingRecords: vi.fn(() => ({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + })), +})) + +vi.mock('@/service/knowledge/use-hit-testing', () => ({ + useHitTesting: vi.fn(() => ({ + mutateAsync: mockHitTestingMutateAsync, + isPending: false, + })), + useExternalKnowledgeBaseHitTesting: vi.fn(() => ({ + mutateAsync: mockExternalHitTestingMutateAsync, + isPending: false, + })), +})) + +// Mock breakpoints hook +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(() => 'pc'), + MediaType: { + mobile: 'mobile', + pc: 'pc', + }, +})) + +// Mock timestamp hook +vi.mock('@/hooks/use-timestamp', () => ({ + default: vi.fn(() => ({ + formatTime: vi.fn((timestamp: number, _format: string) => new Date(timestamp * 1000).toISOString()), + })), +})) + +// Mock use-common to avoid QueryClient issues in nested hooks +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: vi.fn(() => ({ + data: { + file_size_limit: 10, + batch_count_limit: 5, + image_file_size_limit: 5, + }, + isLoading: false, + })), +})) + +// Store ref to ImageUploader onChange for testing +let _mockImageUploaderOnChange: ((files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void) | null = null + +// Mock ImageUploaderInRetrievalTesting to capture onChange +vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ + default: ({ textArea, actionButton, onChange }: { + textArea: React.ReactNode + actionButton: React.ReactNode + onChange: (files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void + }) => { + _mockImageUploaderOnChange = onChange + return ( + <div data-testid="image-uploader-mock"> + {textArea} + {actionButton} + <button + data-testid="trigger-image-change" + onClick={() => onChange([ + { + sourceUrl: 'http://example.com/new-image.png', + uploadedId: 'new-uploaded-id', + mimeType: 'image/png', + name: 'new-image.png', + size: 2000, + extension: 'png', + }, + ])} + > + Add Image + </button> + </div> + ) + }, +})) + +// Mock docLink hook +vi.mock('@/context/i18n', () => ({ + useDocLink: vi.fn(() => () => 'https://docs.example.com'), +})) + +// Mock provider context for retrieval method config +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(() => ({ + supportRetrievalMethods: [ + 'semantic_search', + 'full_text_search', + 'hybrid_search', + ], + })), +})) + +// Mock model list hook - include all exports used by child components +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: vi.fn(() => ({ + data: [], + isLoading: false, + })), + useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({ + modelList: [], + defaultModel: undefined, + currentProvider: undefined, + currentModel: undefined, + })), + useModelListAndDefaultModel: vi.fn(() => ({ + modelList: [], + defaultModel: undefined, + })), + useCurrentProviderAndModel: vi.fn(() => ({ + currentProvider: undefined, + currentModel: undefined, + })), + useDefaultModel: vi.fn(() => ({ + defaultModel: undefined, + })), +})) + +// Test Wrapper with QueryClientProvider + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, +}) + +const TestWrapper = ({ children }: { children: ReactNode }) => { + const queryClient = createTestQueryClient() + return ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ) +} + +const renderWithProviders = (ui: React.ReactElement) => { + return render(ui, { wrapper: TestWrapper }) +} + +// Test Factories + +const createMockSegment = (overrides = {}) => ({ + id: 'segment-1', + document: { + id: 'doc-1', + data_source_type: 'upload_file', + name: 'test-document.pdf', + doc_type: 'book' as const, + }, + content: 'Test segment content', + sign_content: 'Test signed content', + position: 1, + word_count: 100, + tokens: 50, + keywords: ['test', 'keyword'], + hit_count: 5, + index_node_hash: 'hash-123', + answer: '', + ...overrides, +}) + +const createMockHitTesting = (overrides = {}): HitTesting => ({ + segment: createMockSegment() as HitTesting['segment'], + content: createMockSegment() as HitTesting['content'], + score: 0.85, + tsne_position: { x: 0.5, y: 0.5 }, + child_chunks: null, + files: [], + ...overrides, +}) + +const createMockRecord = (overrides = {}): HitTestingRecord => ({ + id: 'record-1', + source: 'hit_testing', + source_app_id: 'app-1', + created_by_role: 'account', + created_by: 'user-1', + created_at: 1609459200, + queries: [ + { content: 'Test query', content_type: 'text_query', file_info: null }, + ], + ...overrides, +}) + +const _createMockRetrievalConfig = (overrides = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 10, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, +} as RetrievalConfig) + +// HitTestingPage Component Tests +// NOTE: Child component unit tests (Score, Mask, EmptyRecords, ResultItemMeta, +// ResultItemFooter, ChildChunksItem, ResultItem, ResultItemExternal, Textarea, +// Records, QueryInput, ModifyExternalRetrievalModal, ModifyRetrievalModal, +// ChunkDetailModal, extensionToFileType) have been moved to their own dedicated +// spec files under the ./components/ and ./utils/ directories. +// This file now focuses exclusively on HitTestingPage integration tests. + +describe('HitTestingPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render page title', () => { + renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // Look for heading element + const heading = screen.getByRole('heading', { level: 1 }) + expect(heading).toBeInTheDocument() + }) + + it('should render records section', () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // The records section should be present + expect(container.querySelector('.flex-col')).toBeInTheDocument() + }) + + it('should render query input', () => { + renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + describe('Loading States', () => { + it('should show loading when records are loading', async () => { + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: undefined, + refetch: mockRecordsRefetch, + isLoading: true, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // Loading component should be visible - look for the loading animation + const loadingElement = container.querySelector('[class*="animate"]') || container.querySelector('.flex-1') + expect(loadingElement).toBeInTheDocument() + }) + }) + + describe('Empty States', () => { + it('should show empty records when no data', () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // EmptyRecords component should be rendered - check that the component is mounted + // The EmptyRecords has a specific structure with bg-workflow-process-bg class + const mainContainer = container.querySelector('.flex.h-full') + expect(mainContainer).toBeInTheDocument() + }) + }) + + describe('Records Display', () => { + it('should display records when data is present', async () => { + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [createMockRecord()], + total: 1, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + expect(screen.getByText('Test query')).toBeInTheDocument() + }) + }) + + describe('Pagination', () => { + it('should show pagination when total exceeds limit', async () => { + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: Array.from({ length: 10 }, (_, i) => createMockRecord({ id: `record-${i}` })), + total: 25, + page: 1, + limit: 10, + has_more: true, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // Pagination should be visible - look for pagination controls + const paginationElement = container.querySelector('[class*="pagination"]') || container.querySelector('nav') + expect(paginationElement || screen.getAllByText('Test query').length > 0).toBeTruthy() + }) + }) + + describe('Right Panel', () => { + it('should render right panel container', () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // The right panel should be present (on non-mobile) + const rightPanel = container.querySelector('.rounded-tl-2xl') + expect(rightPanel).toBeInTheDocument() + }) + }) + + describe('Retrieval Modal', () => { + it('should open retrieval modal when method is clicked', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Find the method selector (cursor-pointer div with the retrieval method) + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find(el => !el.closest('button') && !el.closest('tr')) + + // Verify we found a method selector to click + expect(methodSelector).toBeTruthy() + + if (methodSelector) + fireEvent.click(methodSelector) + + // The component should still be functional after the click + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Hit Results Display', () => { + it('should display hit results when hitResult has records', async () => { + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // The right panel should show empty state initially + expect(container.querySelector('.rounded-tl-2xl')).toBeInTheDocument() + }) + + it('should render loading skeleton when retrieval is in progress', async () => { + const { useHitTesting } = await import('@/service/knowledge/use-hit-testing') + vi.mocked(useHitTesting).mockReturnValue({ + mutateAsync: mockHitTestingMutateAsync, + isPending: true, + } as unknown as ReturnType<typeof useHitTesting>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Component should render without crashing + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render results when hit testing returns data', async () => { + // This test simulates the flow of getting hit results + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // The component should render the result display area + expect(container.querySelector('.bg-background-body')).toBeInTheDocument() + }) + }) + + describe('Record Interaction', () => { + it('should update queries when a record is clicked', async () => { + const mockRecord = createMockRecord({ + queries: [ + { content: 'Record query text', content_type: 'text_query', file_info: null }, + ], + }) + + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [mockRecord], + total: 1, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Find and click the record row + const recordText = screen.getByText('Record query text') + const row = recordText.closest('tr') + if (row) + fireEvent.click(row) + + // The query input should be updated - this causes re-render with new key + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + describe('External Dataset', () => { + it('should render external dataset UI when provider is external', async () => { + // Mock dataset with external provider + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Component should render + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Mobile View', () => { + it('should handle mobile breakpoint', async () => { + // Mock mobile breakpoint + const useBreakpoints = await import('@/hooks/use-breakpoints') + vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType<typeof useBreakpoints.default>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Component should still render + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('useEffect for mobile panel', () => { + it('should update right panel visibility based on mobile state', async () => { + const useBreakpoints = await import('@/hooks/use-breakpoints') + + // First render with desktop + vi.mocked(useBreakpoints.default).mockReturnValue('pc' as unknown as ReturnType<typeof useBreakpoints.default>) + + const { rerender, container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + expect(container.firstChild).toBeInTheDocument() + + // Re-render with mobile + vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType<typeof useBreakpoints.default>) + + rerender( + <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}> + <HitTestingPage datasetId="dataset-1" /> + </QueryClientProvider>, + ) + + expect(container.firstChild).toBeInTheDocument() + }) + }) +}) + +describe('Integration: Hit Testing Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHitTestingMutateAsync.mockReset() + mockExternalHitTestingMutateAsync.mockReset() + }) + + it('should complete a full hit testing flow', async () => { + const mockResponse: HitTestingResponse = { + query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, + records: [createMockHitTesting()], + } + + mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { + options?.onSuccess?.(mockResponse) + return mockResponse + }) + + renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Type query + fireEvent.change(textarea, { target: { value: 'Test query' } }) + + // Find submit button by class + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + expect(submitButton).not.toBeDisabled() + }) + + it('should handle API error gracefully', async () => { + mockHitTestingMutateAsync.mockRejectedValue(new Error('API Error')) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Type query + fireEvent.change(textarea, { target: { value: 'Test query' } }) + + // Component should still be functional - check for the main container + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render hit results after successful submission', async () => { + const mockHitTestingRecord = createMockHitTesting() + const mockResponse: HitTestingResponse = { + query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, + records: [mockHitTestingRecord], + } + + mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { + // Call onSuccess synchronously to ensure state is updated + if (options?.onSuccess) + options.onSuccess(mockResponse) + return mockResponse + }) + + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container: _container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox to be rendered with timeout for CI environment + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Type query + fireEvent.change(textarea, { target: { value: 'Test query' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + if (submitButton) + fireEvent.click(submitButton) + + // Wait for the mutation to complete + await waitFor( + () => { + expect(mockHitTestingMutateAsync).toHaveBeenCalled() + }, + { timeout: 3000 }, + ) + }) + + it('should render ResultItem components for non-external results', async () => { + const mockResponse: HitTestingResponse = { + query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, + records: [ + createMockHitTesting({ score: 0.95 }), + createMockHitTesting({ score: 0.85 }), + ], + } + + mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { + if (options?.onSuccess) + options.onSuccess(mockResponse) + return mockResponse + }) + + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container: _container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for component to be fully rendered with longer timeout + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Submit a query + fireEvent.change(textarea, { target: { value: 'Test query' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + if (submitButton) + fireEvent.click(submitButton) + + // Wait for mutation to complete with longer timeout + await waitFor( + () => { + expect(mockHitTestingMutateAsync).toHaveBeenCalled() + }, + { timeout: 3000 }, + ) + }) + + it('should render external results when dataset is external', async () => { + const mockExternalResponse = { + query: { content: 'test' }, + records: [ + { + title: 'External Result 1', + content: 'External content', + score: 0.9, + metadata: {}, + }, + ], + } + + mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => { + if (options?.onSuccess) + options.onSuccess(mockExternalResponse) + return mockExternalResponse + }) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Component should render + expect(container.firstChild).toBeInTheDocument() + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Type in textarea to verify component is functional + fireEvent.change(textarea, { target: { value: 'Test query' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + if (submitButton) + fireEvent.click(submitButton) + + // Verify component is still functional after submission + await waitFor( + () => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) + }) +}) + +// Drawer and Modal Interaction Tests + +describe('Drawer and Modal Interactions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should save retrieval config when ModifyRetrievalModal onSave is called', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Find and click the retrieval method selector to open the drawer + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find( + el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + ) + + if (methodSelector) { + fireEvent.click(methodSelector) + + await waitFor(() => { + // The drawer should open - verify container is still there + expect(container.firstChild).toBeInTheDocument() + }) + } + + // Component should still be functional - verify main container + expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument() + }) + + it('should close retrieval modal when onHide is called', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Open the modal first + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find( + el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + ) + + if (methodSelector) { + fireEvent.click(methodSelector) + } + + // Component should still be functional + expect(container.firstChild).toBeInTheDocument() + }) +}) + +// renderHitResults Coverage Tests + +describe('renderHitResults Coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHitTestingMutateAsync.mockReset() + }) + + it('should render hit results panel with records count', async () => { + const mockRecords = [ + createMockHitTesting({ score: 0.95 }), + createMockHitTesting({ score: 0.85 }), + ] + const mockResponse: HitTestingResponse = { + query: { content: 'test', tsne_position: { x: 0, y: 0 } }, + records: mockRecords, + } + + // Make mutation call onSuccess synchronously + mockHitTestingMutateAsync.mockImplementation(async (params, options) => { + // Simulate async behavior + await Promise.resolve() + if (options?.onSuccess) + options.onSuccess(mockResponse) + return mockResponse + }) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Enter query + fireEvent.change(textarea, { target: { value: 'test query' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + + if (submitButton) + fireEvent.click(submitButton) + + // Verify component is functional + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should iterate through records and render ResultItem for each', async () => { + const mockRecords = [ + createMockHitTesting({ score: 0.9 }), + ] + + mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { + const response = { query: { content: 'test' }, records: mockRecords } + if (options?.onSuccess) + options.onSuccess(response) + return response + }) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: 'test' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + if (submitButton) + fireEvent.click(submitButton) + + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) +}) + +// Drawer onSave Coverage Tests + +describe('ModifyRetrievalModal onSave Coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should update retrieval config when onSave is triggered', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Open the drawer + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find( + el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + ) + + if (methodSelector) { + fireEvent.click(methodSelector) + + // Wait for drawer to open + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + } + + // Verify component renders correctly + expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument() + }) + + it('should close modal after saving', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Open the drawer + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find( + el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + ) + + if (methodSelector) + fireEvent.click(methodSelector) + + // Component should still be rendered + expect(container.firstChild).toBeInTheDocument() + }) +}) + +// Direct Component Coverage Tests + +describe('HitTestingPage Internal Functions Coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHitTestingMutateAsync.mockReset() + mockExternalHitTestingMutateAsync.mockReset() + }) + + it('should trigger renderHitResults when mutation succeeds with records', async () => { + // Create mock hit testing records + const mockHitRecords = [ + createMockHitTesting({ score: 0.95 }), + createMockHitTesting({ score: 0.85 }), + ] + + const mockResponse: HitTestingResponse = { + query: { content: 'test query', tsne_position: { x: 0, y: 0 } }, + records: mockHitRecords, + } + + // Setup mutation to call onSuccess synchronously + mockHitTestingMutateAsync.mockImplementation((_params, options) => { + // Synchronously call onSuccess + if (options?.onSuccess) + options.onSuccess(mockResponse) + return Promise.resolve(mockResponse) + }) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Enter query and submit + fireEvent.change(textarea, { target: { value: 'test query' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + + if (submitButton) { + fireEvent.click(submitButton) + } + + // Wait for state updates + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }, { timeout: 3000 }) + + // Verify mutation was called + expect(mockHitTestingMutateAsync).toHaveBeenCalled() + }) + + it('should handle retrieval config update via ModifyRetrievalModal', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Find and click retrieval method to open drawer + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find( + el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + ) + + if (methodSelector) { + fireEvent.click(methodSelector) + + // Wait for drawer content + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + + // Try to find save button in the drawer + const saveButtons = screen.queryAllByText(/save/i) + if (saveButtons.length > 0) { + fireEvent.click(saveButtons[0]) + } + } + + // Component should still work + expect(container.firstChild).toBeInTheDocument() + }) + + it('should show hit count in results panel after successful query', async () => { + const mockRecords = [createMockHitTesting()] + const mockResponse: HitTestingResponse = { + query: { content: 'test', tsne_position: { x: 0, y: 0 } }, + records: mockRecords, + } + + mockHitTestingMutateAsync.mockResolvedValue(mockResponse) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Submit a query + fireEvent.change(textarea, { target: { value: 'test' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + + if (submitButton) + fireEvent.click(submitButton) + + // Verify the component renders + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }, { timeout: 3000 }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx new file mode 100644 index 0000000000..6fe1f14983 --- /dev/null +++ b/web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx @@ -0,0 +1,126 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ModifyExternalRetrievalModal from '../modify-external-retrieval-modal' + +vi.mock('@/app/components/base/action-button', () => ({ + default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( + <button data-testid="action-button" onClick={onClick}>{children}</button> + ), +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, variant }: { children: React.ReactNode, onClick: () => void, variant?: string }) => ( + <button data-testid={variant === 'primary' ? 'save-button' : 'cancel-button'} onClick={onClick}> + {children} + </button> + ), +})) + +vi.mock('../../external-knowledge-base/create/RetrievalSettings', () => ({ + default: ({ topK, scoreThreshold, _scoreThresholdEnabled, onChange }: { topK: number, scoreThreshold: number, _scoreThresholdEnabled: boolean, onChange: (data: Record<string, unknown>) => void }) => ( + <div data-testid="retrieval-settings"> + <span data-testid="top-k">{topK}</span> + <span data-testid="score-threshold">{scoreThreshold}</span> + <button data-testid="change-top-k" onClick={() => onChange({ top_k: 10 })}>change top k</button> + <button data-testid="change-score" onClick={() => onChange({ score_threshold: 0.9 })}>change score</button> + <button data-testid="change-enabled" onClick={() => onChange({ score_threshold_enabled: true })}>change enabled</button> + </div> + ), +})) + +describe('ModifyExternalRetrievalModal', () => { + const defaultProps = { + onClose: vi.fn(), + onSave: vi.fn(), + initialTopK: 4, + initialScoreThreshold: 0.5, + initialScoreThresholdEnabled: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render title', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + expect(screen.getByText('datasetHitTesting.settingTitle')).toBeInTheDocument() + }) + + it('should render retrieval settings with initial values', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + expect(screen.getByTestId('top-k')).toHaveTextContent('4') + expect(screen.getByTestId('score-threshold')).toHaveTextContent('0.5') + }) + + it('should call onClose when close button clicked', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('action-button')) + expect(defaultProps.onClose).toHaveBeenCalled() + }) + + it('should call onClose when cancel button clicked', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('cancel-button')) + expect(defaultProps.onClose).toHaveBeenCalled() + }) + + it('should call onSave with current values and close when save clicked', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalledWith({ + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }) + expect(defaultProps.onClose).toHaveBeenCalled() + }) + + it('should save updated values after settings change', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('change-top-k')) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalledWith( + expect.objectContaining({ top_k: 10 }), + ) + }) + + it('should save updated score threshold', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('change-score')) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalledWith( + expect.objectContaining({ score_threshold: 0.9 }), + ) + }) + + it('should save updated score threshold enabled', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('change-enabled')) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalledWith( + expect.objectContaining({ score_threshold_enabled: true }), + ) + }) + + it('should save multiple updated values at once', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('change-top-k')) + fireEvent.click(screen.getByTestId('change-score')) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalledWith( + expect.objectContaining({ top_k: 10, score_threshold: 0.9 }), + ) + }) + + it('should render with different initial values', () => { + const props = { + ...defaultProps, + initialTopK: 10, + initialScoreThreshold: 0.8, + initialScoreThresholdEnabled: true, + } + render(<ModifyExternalRetrievalModal {...props} />) + expect(screen.getByTestId('top-k')).toHaveTextContent('10') + expect(screen.getByTestId('score-threshold')).toHaveTextContent('0.8') + }) +}) diff --git a/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx new file mode 100644 index 0000000000..dafa81971f --- /dev/null +++ b/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx @@ -0,0 +1,108 @@ +import type { RetrievalConfig } from '@/types/app' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RETRIEVE_METHOD } from '@/types/app' +import ModifyRetrievalModal from '../modify-retrieval-modal' + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, variant }: { children: React.ReactNode, onClick: () => void, variant?: string }) => ( + <button data-testid={variant === 'primary' ? 'save-button' : 'cancel-button'} onClick={onClick}> + {children} + </button> + ), +})) + +vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ + isReRankModelSelected: vi.fn(() => true), +})) + +vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ + default: ({ value, onChange }: { value: RetrievalConfig, onChange: (v: RetrievalConfig) => void }) => ( + <div data-testid="retrieval-method-config"> + <span>{value.search_method}</span> + <button data-testid="change-config" onClick={() => onChange({ ...value, search_method: RETRIEVE_METHOD.hybrid })}>change</button> + </div> + ), +})) + +vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({ + default: () => <div data-testid="economical-config" />, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: () => ({ data: [] }), +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: () => 'model-name', +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, +})) + +vi.mock('../../../base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('../../settings/utils', () => ({ + checkShowMultiModalTip: () => false, +})) + +describe('ModifyRetrievalModal', () => { + const defaultProps = { + indexMethod: 'high_quality', + value: { + search_method: 'semantic_search', + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + } as RetrievalConfig, + isShow: true, + onHide: vi.fn(), + onSave: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return null when isShow is false', () => { + const { container } = render(<ModifyRetrievalModal {...defaultProps} isShow={false} />) + expect(container.firstChild).toBeNull() + }) + + it('should render title when isShow is true', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + expect(screen.getByText('datasetSettings.form.retrievalSetting.title')).toBeInTheDocument() + }) + + it('should render high quality retrieval config for high_quality index', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument() + }) + + it('should render economical config for non high_quality index', () => { + render(<ModifyRetrievalModal {...defaultProps} indexMethod="economy" />) + expect(screen.getByTestId('economical-config')).toBeInTheDocument() + }) + + it('should call onHide when cancel button clicked', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('cancel-button')) + expect(defaultProps.onHide).toHaveBeenCalled() + }) + + it('should call onSave with retrieval config when save clicked', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalled() + }) + + it('should render learn more link', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + expect(screen.getByText('datasetSettings.form.retrievalSetting.learnMore')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx new file mode 100644 index 0000000000..9428f0ad45 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx @@ -0,0 +1,97 @@ +import type { HitTestingChildChunk } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ChildChunksItem from '../child-chunks-item' + +const createChildChunkPayload = ( + overrides: Partial<HitTestingChildChunk> = {}, +): HitTestingChildChunk => ({ + id: 'chunk-1', + content: 'Child chunk content here', + position: 1, + score: 0.75, + ...overrides, +}) + +describe('ChildChunksItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for child chunk items + describe('Rendering', () => { + it('should render the position label', () => { + const payload = createChildChunkPayload({ position: 3 }) + + render(<ChildChunksItem payload={payload} isShowAll={false} />) + + expect(screen.getByText(/C-/)).toBeInTheDocument() + expect(screen.getByText(/3/)).toBeInTheDocument() + }) + + it('should render the score component', () => { + const payload = createChildChunkPayload({ score: 0.88 }) + + render(<ChildChunksItem payload={payload} isShowAll={false} />) + + expect(screen.getByText('0.88')).toBeInTheDocument() + }) + + it('should render the content text', () => { + const payload = createChildChunkPayload({ content: 'Sample chunk text' }) + + render(<ChildChunksItem payload={payload} isShowAll={false} />) + + expect(screen.getByText('Sample chunk text')).toBeInTheDocument() + }) + + it('should render with besideChunkName styling on Score', () => { + const payload = createChildChunkPayload({ score: 0.6 }) + + const { container } = render( + <ChildChunksItem payload={payload} isShowAll={false} />, + ) + + // Assert - Score with besideChunkName has h-[20.5px] and border-l-0 + const scoreEl = container.querySelector('[class*="h-\\[20\\.5px\\]"]') + expect(scoreEl).toBeInTheDocument() + }) + }) + + // Line clamping behavior tests + describe('Line Clamping', () => { + it('should apply line-clamp-2 when isShowAll is false', () => { + const payload = createChildChunkPayload() + + const { container } = render( + <ChildChunksItem payload={payload} isShowAll={false} />, + ) + + const root = container.firstElementChild + expect(root?.className).toContain('line-clamp-2') + }) + + it('should not apply line-clamp-2 when isShowAll is true', () => { + const payload = createChildChunkPayload() + + const { container } = render( + <ChildChunksItem payload={payload} isShowAll={true} />, + ) + + const root = container.firstElementChild + expect(root?.className).not.toContain('line-clamp-2') + }) + }) + + describe('Edge Cases', () => { + it('should render with score 0 (Score returns null)', () => { + const payload = createChildChunkPayload({ score: 0 }) + + render(<ChildChunksItem payload={payload} isShowAll={false} />) + + // Assert - content still renders, score returns null + expect(screen.getByText('Child chunk content here')).toBeInTheDocument() + expect(screen.queryByText('score')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx new file mode 100644 index 0000000000..109d2f9cfe --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx @@ -0,0 +1,137 @@ +import type { HitTesting } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ChunkDetailModal from '../chunk-detail-modal' + +vi.mock('@/app/components/base/file-uploader/file-type-icon', () => ({ + default: () => <span data-testid="file-icon" />, +})) + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>, +})) + +vi.mock('@/app/components/base/modal', () => ({ + default: ({ children, title, onClose }: { children: React.ReactNode, title: string, onClose: () => void }) => ( + <div data-testid="modal"> + <div data-testid="modal-title">{title}</div> + <button data-testid="modal-close" onClick={onClose}>close</button> + {children} + </div> + ), +})) + +vi.mock('../../../common/image-list', () => ({ + default: () => <div data-testid="image-list" />, +})) + +vi.mock('../../../documents/detail/completed/common/dot', () => ({ + default: () => <span data-testid="dot" />, +})) + +vi.mock('../../../documents/detail/completed/common/segment-index-tag', () => ({ + SegmentIndexTag: ({ positionId }: { positionId: number }) => <span data-testid="segment-index-tag">{positionId}</span>, +})) + +vi.mock('../../../documents/detail/completed/common/summary-text', () => ({ + default: ({ value }: { value: string }) => <div data-testid="summary-text">{value}</div>, +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/tag', () => ({ + default: ({ text }: { text: string }) => <span data-testid="tag">{text}</span>, +})) + +vi.mock('../child-chunks-item', () => ({ + default: ({ payload }: { payload: { id: string } }) => <div data-testid="child-chunk">{payload.id}</div>, +})) + +vi.mock('../mask', () => ({ + default: () => <div data-testid="mask" />, +})) + +vi.mock('../score', () => ({ + default: ({ value }: { value: number }) => <span data-testid="score">{value}</span>, +})) + +const makePayload = (overrides: Record<string, unknown> = {}): HitTesting => { + const segmentOverrides = (overrides.segment ?? {}) as Record<string, unknown> + const segment = { + position: 1, + content: 'chunk content', + sign_content: '', + keywords: [], + document: { name: 'file.pdf' }, + answer: '', + word_count: 100, + ...segmentOverrides, + } + return { + segment, + content: segment, + score: 0.85, + tsne_position: { x: 0, y: 0 }, + child_chunks: (overrides.child_chunks ?? []) as HitTesting['child_chunks'], + files: (overrides.files ?? []) as HitTesting['files'], + summary: (overrides.summary ?? '') as string, + } as unknown as HitTesting +} + +describe('ChunkDetailModal', () => { + const onHide = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render modal with title', () => { + render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) + expect(screen.getByTestId('modal-title')).toHaveTextContent('chunkDetail') + }) + + it('should render segment index tag and score', () => { + render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) + expect(screen.getByTestId('segment-index-tag')).toHaveTextContent('1') + expect(screen.getByTestId('score')).toHaveTextContent('0.85') + }) + + it('should render markdown content', () => { + render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) + expect(screen.getByTestId('markdown')).toHaveTextContent('chunk content') + }) + + it('should render QA content when answer exists', () => { + const payload = makePayload({ + segment: { answer: 'answer text', content: 'question text' }, + }) + render(<ChunkDetailModal payload={payload} onHide={onHide} />) + expect(screen.getByText('question text')).toBeInTheDocument() + expect(screen.getByText('answer text')).toBeInTheDocument() + }) + + it('should render keywords when present and not parent-child', () => { + const payload = makePayload({ + segment: { keywords: ['k1', 'k2'] }, + }) + render(<ChunkDetailModal payload={payload} onHide={onHide} />) + expect(screen.getAllByTestId('tag')).toHaveLength(2) + }) + + it('should render child chunks section for parent-child retrieval', () => { + const payload = makePayload({ + child_chunks: [{ id: 'c1' }, { id: 'c2' }], + }) + render(<ChunkDetailModal payload={payload} onHide={onHide} />) + expect(screen.getAllByTestId('child-chunk')).toHaveLength(2) + }) + + it('should render summary text when summary exists', () => { + const payload = makePayload({ summary: 'test summary' }) + render(<ChunkDetailModal payload={payload} onHide={onHide} />) + expect(screen.getByTestId('summary-text')).toHaveTextContent('test summary') + }) + + it('should render mask overlay', () => { + render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) + expect(screen.getByTestId('mask')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/empty-records.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/empty-records.spec.tsx new file mode 100644 index 0000000000..7bcb88a845 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/empty-records.spec.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import EmptyRecords from '../empty-records' + +describe('EmptyRecords', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the empty state component + describe('Rendering', () => { + it('should render the "no recent" tip text', () => { + render(<EmptyRecords />) + + expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument() + }) + + it('should render the history icon', () => { + const { container } = render(<EmptyRecords />) + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should render inside a styled container', () => { + const { container } = render(<EmptyRecords />) + + const wrapper = container.firstElementChild + expect(wrapper?.className).toContain('rounded-2xl') + expect(wrapper?.className).toContain('bg-workflow-process-bg') + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/mask.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/mask.spec.tsx new file mode 100644 index 0000000000..8c4e2b3251 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/mask.spec.tsx @@ -0,0 +1,33 @@ +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Mask from '../mask' + +describe('Mask', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the gradient overlay component + describe('Rendering', () => { + it('should render a gradient overlay div', () => { + const { container } = render(<Mask />) + + const div = container.firstElementChild + expect(div).toBeInTheDocument() + expect(div?.className).toContain('h-12') + expect(div?.className).toContain('bg-gradient-to-b') + }) + + it('should apply custom className', () => { + const { container } = render(<Mask className="custom-mask" />) + + expect(container.firstElementChild?.className).toContain('custom-mask') + }) + + it('should render without custom className', () => { + const { container } = render(<Mask />) + + expect(container.firstElementChild).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx new file mode 100644 index 0000000000..649dcc4d25 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx @@ -0,0 +1,95 @@ +import type { HitTestingRecord } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Records from '../records' + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: (ts: number, _fmt: string) => `time-${ts}`, + }), +})) + +vi.mock('../../../common/image-list', () => ({ + default: () => <div data-testid="image-list" />, +})) + +const makeRecord = (id: string, source: string, created_at: number, content = 'query text') => ({ + id, + source, + created_at, + queries: [{ content, content_type: 'text_query', file_info: null }], +}) as unknown as HitTestingRecord + +describe('Records', () => { + const mockOnClick = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render table headers', () => { + render(<Records records={[]} onClickRecord={mockOnClick} />) + expect(screen.getByText('datasetHitTesting.table.header.queryContent')).toBeInTheDocument() + expect(screen.getByText('datasetHitTesting.table.header.source')).toBeInTheDocument() + expect(screen.getByText('datasetHitTesting.table.header.time')).toBeInTheDocument() + }) + + it('should render records', () => { + const records = [ + makeRecord('1', 'app', 1000), + makeRecord('2', 'hit_testing', 2000), + ] + render(<Records records={records} onClickRecord={mockOnClick} />) + expect(screen.getAllByText('query text')).toHaveLength(2) + }) + + it('should call onClickRecord when row clicked', () => { + const records = [makeRecord('1', 'app', 1000)] + render(<Records records={records} onClickRecord={mockOnClick} />) + fireEvent.click(screen.getByText('query text')) + expect(mockOnClick).toHaveBeenCalledWith(records[0]) + }) + + it('should sort records by time descending by default', () => { + const records = [ + makeRecord('1', 'app', 1000, 'early'), + makeRecord('2', 'app', 3000, 'late'), + makeRecord('3', 'app', 2000, 'mid'), + ] + render(<Records records={records} onClickRecord={mockOnClick} />) + const rows = screen.getAllByRole('row').slice(1) // skip header + expect(rows[0]).toHaveTextContent('late') + expect(rows[1]).toHaveTextContent('mid') + expect(rows[2]).toHaveTextContent('early') + }) + + it('should toggle sort order on time header click', () => { + const records = [ + makeRecord('1', 'app', 1000, 'early'), + makeRecord('2', 'app', 3000, 'late'), + ] + render(<Records records={records} onClickRecord={mockOnClick} />) + + // Default: desc, so late first + let rows = screen.getAllByRole('row').slice(1) + expect(rows[0]).toHaveTextContent('late') + + fireEvent.click(screen.getByText('datasetHitTesting.table.header.time')) + rows = screen.getAllByRole('row').slice(1) + expect(rows[0]).toHaveTextContent('early') + }) + + it('should render image list for image queries', () => { + const records = [{ + id: '1', + source: 'app', + created_at: 1000, + queries: [ + { content: '', content_type: 'text_query', file_info: null }, + { content: '', content_type: 'image_query', file_info: { name: 'img.png', mime_type: 'image/png', source_url: 'url', size: 100, extension: 'png' } }, + ], + }] as unknown as HitTestingRecord[] + render(<Records records={records} onClickRecord={mockOnClick} />) + expect(screen.getByTestId('image-list')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx new file mode 100644 index 0000000000..b1a4aa5f57 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx @@ -0,0 +1,173 @@ +import type { ExternalKnowledgeBaseHitTesting } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ResultItemExternal from '../result-item-external' + +let mockIsShowDetailModal = false +const mockShowDetailModal = vi.fn(() => { + mockIsShowDetailModal = true +}) +const mockHideDetailModal = vi.fn(() => { + mockIsShowDetailModal = false +}) + +// Mock useBoolean: required because tests control modal state externally +// (setting mockIsShowDetailModal before render) and verify mock fn calls. +vi.mock('ahooks', () => ({ + useBoolean: (_initial: boolean) => { + return [ + mockIsShowDetailModal, + { + setTrue: mockShowDetailModal, + setFalse: mockHideDetailModal, + toggle: vi.fn(), + set: vi.fn(), + }, + ] + }, +})) + +const createExternalPayload = ( + overrides: Partial<ExternalKnowledgeBaseHitTesting> = {}, +): ExternalKnowledgeBaseHitTesting => ({ + content: 'This is the chunk content for testing.', + title: 'Test Document Title', + score: 0.85, + metadata: { + 'x-amz-bedrock-kb-source-uri': 's3://bucket/key', + 'x-amz-bedrock-kb-data-source-id': 'ds-123', + }, + ...overrides, +}) + +describe('ResultItemExternal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsShowDetailModal = false + }) + + // Rendering tests for the external result item card + describe('Rendering', () => { + it('should render the content text', () => { + const payload = createExternalPayload({ content: 'External result content' }) + + render(<ResultItemExternal payload={payload} positionId={1} />) + + expect(screen.getByText('External result content')).toBeInTheDocument() + }) + + it('should render the meta info with position and score', () => { + const payload = createExternalPayload({ score: 0.92 }) + + render(<ResultItemExternal payload={payload} positionId={5} />) + + expect(screen.getByText('Chunk-05')).toBeInTheDocument() + expect(screen.getByText('0.92')).toBeInTheDocument() + }) + + it('should render the footer with document title', () => { + const payload = createExternalPayload({ title: 'Knowledge Base Doc' }) + + render(<ResultItemExternal payload={payload} positionId={1} />) + + expect(screen.getByText('Knowledge Base Doc')).toBeInTheDocument() + }) + + it('should render the word count from content length', () => { + const content = 'Hello World' // 11 chars + const payload = createExternalPayload({ content }) + + render(<ResultItemExternal payload={payload} positionId={1} />) + + expect(screen.getByText(/11/)).toBeInTheDocument() + }) + }) + + // Detail modal tests + describe('Detail Modal', () => { + it('should not render modal by default', () => { + const payload = createExternalPayload() + + render(<ResultItemExternal payload={payload} positionId={1} />) + + expect(screen.queryByText(/chunkDetail/i)).not.toBeInTheDocument() + }) + + it('should call showDetailModal when card is clicked', () => { + const payload = createExternalPayload() + mockIsShowDetailModal = false + + render(<ResultItemExternal payload={payload} positionId={1} />) + + // Act - click the card to open modal + const card = screen.getByText(payload.content).closest('.cursor-pointer') as HTMLElement + fireEvent.click(card) + + // Assert - showDetailModal (setTrue) was invoked + expect(mockShowDetailModal).toHaveBeenCalled() + }) + + it('should render modal content when isShowDetailModal is true', () => { + // Arrange - modal is already open + const payload = createExternalPayload() + mockIsShowDetailModal = true + + render(<ResultItemExternal payload={payload} positionId={1} />) + + // Assert - modal title should appear + expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() + }) + + it('should render full content in the modal', () => { + const payload = createExternalPayload({ content: 'Full modal content text' }) + mockIsShowDetailModal = true + + render(<ResultItemExternal payload={payload} positionId={1} />) + + // Assert - content appears both in card and modal + const contentElements = screen.getAllByText('Full modal content text') + expect(contentElements.length).toBeGreaterThanOrEqual(2) + }) + + it('should render meta info in the modal', () => { + const payload = createExternalPayload({ score: 0.77 }) + mockIsShowDetailModal = true + + render(<ResultItemExternal payload={payload} positionId={3} />) + + // Assert - meta appears in both card and modal + const chunkTags = screen.getAllByText('Chunk-03') + expect(chunkTags.length).toBe(2) + const scores = screen.getAllByText('0.77') + expect(scores.length).toBe(2) + }) + }) + + describe('Edge Cases', () => { + it('should render with empty content', () => { + const payload = createExternalPayload({ content: '' }) + + render(<ResultItemExternal payload={payload} positionId={1} />) + + // Assert - component still renders + expect(screen.getByText('Test Document Title')).toBeInTheDocument() + }) + + it('should render with score of 0 (Score returns null)', () => { + const payload = createExternalPayload({ score: 0 }) + + render(<ResultItemExternal payload={payload} positionId={1} />) + + // Assert - no score displayed + expect(screen.queryByText('score')).not.toBeInTheDocument() + }) + + it('should handle large positionId values', () => { + const payload = createExternalPayload() + + render(<ResultItemExternal payload={payload} positionId={999} />) + + expect(screen.getByText('Chunk-999')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx new file mode 100644 index 0000000000..44a7dc2c89 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' +import ResultItemFooter from '../result-item-footer' + +describe('ResultItemFooter', () => { + const mockShowDetailModal = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the result item footer + describe('Rendering', () => { + it('should render the document title', () => { + render( + <ResultItemFooter + docType={FileAppearanceTypeEnum.document} + docTitle="My Document.pdf" + showDetailModal={mockShowDetailModal} + />, + ) + + expect(screen.getByText('My Document.pdf')).toBeInTheDocument() + }) + + it('should render the "open" button text', () => { + render( + <ResultItemFooter + docType={FileAppearanceTypeEnum.pdf} + docTitle="File.pdf" + showDetailModal={mockShowDetailModal} + />, + ) + + expect(screen.getByText(/open/i)).toBeInTheDocument() + }) + + it('should render the file icon', () => { + const { container } = render( + <ResultItemFooter + docType={FileAppearanceTypeEnum.document} + docTitle="File.txt" + showDetailModal={mockShowDetailModal} + />, + ) + + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + }) + + // User interaction tests + describe('User Interactions', () => { + it('should call showDetailModal when open button is clicked', () => { + render( + <ResultItemFooter + docType={FileAppearanceTypeEnum.document} + docTitle="Doc" + showDetailModal={mockShowDetailModal} + />, + ) + + const openButton = screen.getByText(/open/i) + fireEvent.click(openButton) + + expect(mockShowDetailModal).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx new file mode 100644 index 0000000000..0cd32ee82c --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ResultItemMeta from '../result-item-meta' + +describe('ResultItemMeta', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the result item meta component + describe('Rendering', () => { + it('should render the segment index tag with prefix and position', () => { + render( + <ResultItemMeta + labelPrefix="Chunk" + positionId={3} + wordCount={150} + score={0.9} + />, + ) + + expect(screen.getByText('Chunk-03')).toBeInTheDocument() + }) + + it('should render the word count', () => { + render( + <ResultItemMeta + labelPrefix="Chunk" + positionId={1} + wordCount={250} + score={0.8} + />, + ) + + expect(screen.getByText(/250/)).toBeInTheDocument() + expect(screen.getByText(/characters/i)).toBeInTheDocument() + }) + + it('should render the score component', () => { + render( + <ResultItemMeta + labelPrefix="Chunk" + positionId={1} + wordCount={100} + score={0.75} + />, + ) + + expect(screen.getByText('0.75')).toBeInTheDocument() + expect(screen.getByText('score')).toBeInTheDocument() + }) + + it('should apply custom className', () => { + const { container } = render( + <ResultItemMeta + className="custom-meta" + labelPrefix="Chunk" + positionId={1} + wordCount={100} + score={0.5} + />, + ) + + expect(container.firstElementChild?.className).toContain('custom-meta') + }) + + it('should render dot separator', () => { + render( + <ResultItemMeta + labelPrefix="Chunk" + positionId={1} + wordCount={100} + score={0.5} + />, + ) + + expect(screen.getByText('·')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx new file mode 100644 index 0000000000..c8ef054181 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx @@ -0,0 +1,144 @@ +import type { HitTesting } from '@/models/datasets' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ResultItem from '../result-item' + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>, +})) + +vi.mock('../../../common/image-list', () => ({ + default: () => <div data-testid="image-list" />, +})) + +vi.mock('../child-chunks-item', () => ({ + default: ({ payload }: { payload: { id: string } }) => <div data-testid="child-chunk">{payload.id}</div>, +})) + +vi.mock('../chunk-detail-modal', () => ({ + default: () => <div data-testid="chunk-detail-modal" />, +})) + +vi.mock('../result-item-footer', () => ({ + default: ({ docTitle }: { docTitle: string }) => <div data-testid="result-item-footer">{docTitle}</div>, +})) + +vi.mock('../result-item-meta', () => ({ + default: ({ positionId }: { positionId: number }) => <div data-testid="result-item-meta">{positionId}</div>, +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/summary-label', () => ({ + default: ({ summary }: { summary: string }) => <div data-testid="summary-label">{summary}</div>, +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/tag', () => ({ + default: ({ text }: { text: string }) => <span data-testid="tag">{text}</span>, +})) + +vi.mock('@/app/components/datasets/hit-testing/utils/extension-to-file-type', () => ({ + extensionToFileType: () => 'pdf', +})) + +const makePayload = (overrides: Record<string, unknown> = {}): HitTesting => { + const segmentOverrides = (overrides.segment ?? {}) as Record<string, unknown> + const segment = { + position: 1, + word_count: 100, + content: 'test content', + sign_content: '', + keywords: [], + document: { name: 'file.pdf' }, + answer: '', + ...segmentOverrides, + } + return { + segment, + content: segment, + score: 0.95, + tsne_position: { x: 0, y: 0 }, + child_chunks: (overrides.child_chunks ?? []) as HitTesting['child_chunks'], + files: (overrides.files ?? []) as HitTesting['files'], + summary: (overrides.summary ?? '') as string, + } as unknown as HitTesting +} + +describe('ResultItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render meta, content, and footer', () => { + render(<ResultItem payload={makePayload()} />) + expect(screen.getByTestId('result-item-meta')).toHaveTextContent('1') + expect(screen.getByTestId('markdown')).toHaveTextContent('test content') + expect(screen.getByTestId('result-item-footer')).toHaveTextContent('file.pdf') + }) + + it('should render keywords when no child_chunks', () => { + const payload = makePayload({ + segment: { keywords: ['key1', 'key2'] }, + }) + render(<ResultItem payload={payload} />) + expect(screen.getAllByTestId('tag')).toHaveLength(2) + }) + + it('should render child chunks when present', () => { + const payload = makePayload({ + child_chunks: [{ id: 'c1' }, { id: 'c2' }], + }) + render(<ResultItem payload={payload} />) + expect(screen.getAllByTestId('child-chunk')).toHaveLength(2) + }) + + it('should render summary label when summary exists', () => { + const payload = makePayload({ summary: 'test summary' }) + render(<ResultItem payload={payload} />) + expect(screen.getByTestId('summary-label')).toHaveTextContent('test summary') + }) + + it('should show chunk detail modal on click', () => { + render(<ResultItem payload={makePayload()} />) + fireEvent.click(screen.getByTestId('markdown')) + expect(screen.getByTestId('chunk-detail-modal')).toBeInTheDocument() + }) + + it('should render images when files exist', () => { + const payload = makePayload({ + files: [{ name: 'img.png', mime_type: 'image/png', source_url: 'url', size: 100, extension: 'png' }], + }) + render(<ResultItem payload={payload} />) + expect(screen.getByTestId('image-list')).toBeInTheDocument() + }) + + it('should not render keywords when child_chunks are present', () => { + const payload = makePayload({ + segment: { keywords: ['k1'] }, + child_chunks: [{ id: 'c1' }], + }) + render(<ResultItem payload={payload} />) + expect(screen.queryByTestId('tag')).not.toBeInTheDocument() + }) + + it('should not render keywords section when keywords array is empty', () => { + const payload = makePayload({ + segment: { keywords: [] }, + }) + render(<ResultItem payload={payload} />) + expect(screen.queryByTestId('tag')).not.toBeInTheDocument() + }) + + it('should toggle child chunks fold state', async () => { + const payload = makePayload({ + child_chunks: [{ id: 'c1' }], + }) + render(<ResultItem payload={payload} />) + expect(screen.getByTestId('child-chunk')).toBeInTheDocument() + + const header = screen.getByText(/hitChunks/i) + fireEvent.click(header.closest('div')!) + + await waitFor(() => { + expect(screen.queryByTestId('child-chunk')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/score.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/score.spec.tsx new file mode 100644 index 0000000000..7fbaf45e5d --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/score.spec.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Score from '../score' + +describe('Score', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the score display component + describe('Rendering', () => { + it('should render score value with toFixed(2)', () => { + render(<Score value={0.85} />) + + expect(screen.getByText('0.85')).toBeInTheDocument() + expect(screen.getByText('score')).toBeInTheDocument() + }) + + it('should render score progress bar with correct width', () => { + const { container } = render(<Score value={0.75} />) + + const progressBar = container.querySelector('[style]') + expect(progressBar).toHaveStyle({ width: '75%' }) + }) + + it('should render with besideChunkName styling', () => { + const { container } = render(<Score value={0.5} besideChunkName />) + + const root = container.firstElementChild + expect(root?.className).toContain('h-[20.5px]') + expect(root?.className).toContain('border-l-0') + }) + + it('should render with default styling when besideChunkName is false', () => { + const { container } = render(<Score value={0.5} />) + + const root = container.firstElementChild + expect(root?.className).toContain('h-[20px]') + expect(root?.className).toContain('rounded-md') + }) + + it('should remove right border when value is exactly 1', () => { + const { container } = render(<Score value={1} />) + + const progressBar = container.querySelector('[style]') + expect(progressBar?.className).toContain('border-r-0') + expect(progressBar).toHaveStyle({ width: '100%' }) + }) + + it('should show right border when value is less than 1', () => { + const { container } = render(<Score value={0.5} />) + + const progressBar = container.querySelector('[style]') + expect(progressBar?.className).not.toContain('border-r-0') + }) + }) + + // Null return tests for edge cases + describe('Returns null', () => { + it('should return null when value is null', () => { + const { container } = render(<Score value={null} />) + + expect(container.innerHTML).toBe('') + }) + + it('should return null when value is 0', () => { + const { container } = render(<Score value={0} />) + + expect(container.innerHTML).toBe('') + }) + + it('should return null when value is NaN', () => { + const { container } = render(<Score value={Number.NaN} />) + + expect(container.innerHTML).toBe('') + }) + }) + + describe('Edge Cases', () => { + it('should render very small score values', () => { + render(<Score value={0.01} />) + + expect(screen.getByText('0.01')).toBeInTheDocument() + }) + + it('should render score with many decimals truncated to 2', () => { + render(<Score value={0.123456} />) + + expect(screen.getByText('0.12')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx new file mode 100644 index 0000000000..b00e430575 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx @@ -0,0 +1,111 @@ +import type { Query } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import QueryInput from '../index' + +vi.mock('uuid', () => ({ + v4: () => 'mock-uuid', +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, disabled, loading }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, loading?: boolean }) => ( + <button data-testid="submit-button" onClick={onClick} disabled={disabled || loading}> + {children} + </button> + ), +})) + +vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ + default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => ( + <div data-testid="image-uploader"> + {textArea} + {actionButton} + </div> + ), +})) + +vi.mock('@/app/components/datasets/common/retrieval-method-info', () => ({ + getIcon: () => '/test-icon.png', +})) + +vi.mock('@/app/components/datasets/hit-testing/modify-external-retrieval-modal', () => ({ + default: () => <div data-testid="external-retrieval-modal" />, +})) + +vi.mock('../textarea', () => ({ + default: ({ text }: { text: string }) => <textarea data-testid="textarea" defaultValue={text} />, +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: () => false, +})) + +describe('QueryInput', () => { + const defaultProps = { + onUpdateList: vi.fn(), + setHitResult: vi.fn(), + setExternalHitResult: vi.fn(), + loading: false, + queries: [{ content: 'test query', content_type: 'text_query', file_info: null }] satisfies Query[], + setQueries: vi.fn(), + isExternal: false, + onClickRetrievalMethod: vi.fn(), + retrievalConfig: { search_method: 'semantic_search' } as RetrievalConfig, + isEconomy: false, + hitTestingMutation: vi.fn(), + externalKnowledgeBaseHitTestingMutation: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render title', () => { + render(<QueryInput {...defaultProps} />) + expect(screen.getByText('datasetHitTesting.input.title')).toBeInTheDocument() + }) + + it('should render textarea with query text', () => { + render(<QueryInput {...defaultProps} />) + expect(screen.getByTestId('textarea')).toBeInTheDocument() + }) + + it('should render submit button', () => { + render(<QueryInput {...defaultProps} />) + expect(screen.getByTestId('submit-button')).toBeInTheDocument() + }) + + it('should disable submit button when text is empty', () => { + const props = { + ...defaultProps, + queries: [{ content: '', content_type: 'text_query', file_info: null }] satisfies Query[], + } + render(<QueryInput {...props} />) + expect(screen.getByTestId('submit-button')).toBeDisabled() + }) + + it('should render retrieval method for non-external mode', () => { + render(<QueryInput {...defaultProps} />) + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + }) + + it('should render settings button for external mode', () => { + render(<QueryInput {...defaultProps} isExternal={true} />) + expect(screen.getByText('datasetHitTesting.settingTitle')).toBeInTheDocument() + }) + + it('should disable submit button when text exceeds 200 characters', () => { + const props = { + ...defaultProps, + queries: [{ content: 'a'.repeat(201), content_type: 'text_query', file_info: null }] satisfies Query[], + } + render(<QueryInput {...props} />) + expect(screen.getByTestId('submit-button')).toBeDisabled() + }) + + it('should disable submit button when loading', () => { + render(<QueryInput {...defaultProps} loading={true} />) + expect(screen.getByTestId('submit-button')).toBeDisabled() + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx b/web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx new file mode 100644 index 0000000000..c7d5f8f799 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Textarea from '../textarea' + +describe('Textarea', () => { + const mockHandleTextChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the textarea with character count + describe('Rendering', () => { + it('should render a textarea element', () => { + render(<Textarea text="" handleTextChange={mockHandleTextChange} />) + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should display the current text', () => { + render(<Textarea text="Hello world" handleTextChange={mockHandleTextChange} />) + + expect(screen.getByRole('textbox')).toHaveValue('Hello world') + }) + + it('should show character count', () => { + render(<Textarea text="Hello" handleTextChange={mockHandleTextChange} />) + + expect(screen.getByText('5/200')).toBeInTheDocument() + }) + + it('should show 0/200 for empty text', () => { + render(<Textarea text="" handleTextChange={mockHandleTextChange} />) + + expect(screen.getByText('0/200')).toBeInTheDocument() + }) + + it('should render placeholder text', () => { + render(<Textarea text="" handleTextChange={mockHandleTextChange} />) + + expect(screen.getByRole('textbox')).toHaveAttribute('placeholder') + }) + }) + + // Warning state tests for exceeding character limit + describe('Warning state (>200 chars)', () => { + it('should apply warning border when text exceeds 200 characters', () => { + const longText = 'A'.repeat(201) + + const { container } = render( + <Textarea text={longText} handleTextChange={mockHandleTextChange} />, + ) + + const wrapper = container.firstElementChild + expect(wrapper?.className).toContain('border-state-destructive-active') + }) + + it('should not apply warning border when text is at 200 characters', () => { + const text200 = 'A'.repeat(200) + + const { container } = render( + <Textarea text={text200} handleTextChange={mockHandleTextChange} />, + ) + + const wrapper = container.firstElementChild + expect(wrapper?.className).not.toContain('border-state-destructive-active') + }) + + it('should not apply warning border when text is under 200 characters', () => { + const { container } = render( + <Textarea text="Short text" handleTextChange={mockHandleTextChange} />, + ) + + const wrapper = container.firstElementChild + expect(wrapper?.className).not.toContain('border-state-destructive-active') + }) + + it('should show warning count with red styling when over 200 chars', () => { + const longText = 'B'.repeat(250) + + render(<Textarea text={longText} handleTextChange={mockHandleTextChange} />) + + const countElement = screen.getByText('250/200') + expect(countElement.className).toContain('text-util-colors-red-red-600') + }) + + it('should show normal count styling when at or under 200 chars', () => { + render(<Textarea text="Short" handleTextChange={mockHandleTextChange} />) + + const countElement = screen.getByText('5/200') + expect(countElement.className).toContain('text-text-tertiary') + }) + + it('should show red corner icon when over 200 chars', () => { + const longText = 'C'.repeat(201) + + const { container } = render( + <Textarea text={longText} handleTextChange={mockHandleTextChange} />, + ) + + // Assert - Corner icon should have red class + const cornerWrapper = container.querySelector('.right-0.top-0') + const cornerSvg = cornerWrapper?.querySelector('svg') + expect(cornerSvg?.className.baseVal || cornerSvg?.getAttribute('class')).toContain('text-util-colors-red-red-100') + }) + }) + + // User interaction tests + describe('User Interactions', () => { + it('should call handleTextChange when text is entered', () => { + render(<Textarea text="" handleTextChange={mockHandleTextChange} />) + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'New text' }, + }) + + expect(mockHandleTextChange).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/index.spec.tsx b/web/app/components/datasets/hit-testing/index.spec.tsx deleted file mode 100644 index 07a78cd55f..0000000000 --- a/web/app/components/datasets/hit-testing/index.spec.tsx +++ /dev/null @@ -1,2704 +0,0 @@ -import type { ReactNode } from 'react' -import type { DataSet, HitTesting, HitTestingChildChunk, HitTestingRecord, HitTestingResponse, Query } from '@/models/datasets' -import type { RetrievalConfig } from '@/types/app' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' -import { RETRIEVE_METHOD } from '@/types/app' - -// ============================================================================ -// Imports (after mocks) -// ============================================================================ - -import ChildChunksItem from './components/child-chunks-item' -import ChunkDetailModal from './components/chunk-detail-modal' -import EmptyRecords from './components/empty-records' -import Mask from './components/mask' -import QueryInput from './components/query-input' -import Textarea from './components/query-input/textarea' -import Records from './components/records' -import ResultItem from './components/result-item' -import ResultItemExternal from './components/result-item-external' -import ResultItemFooter from './components/result-item-footer' -import ResultItemMeta from './components/result-item-meta' -import Score from './components/score' -import HitTestingPage from './index' -import ModifyExternalRetrievalModal from './modify-external-retrieval-modal' -import ModifyRetrievalModal from './modify-retrieval-modal' -import { extensionToFileType } from './utils/extension-to-file-type' - -// Mock Toast -// Note: These components use real implementations for integration testing: -// - Toast, FloatRightContainer, Drawer, Pagination, Loading -// - RetrievalMethodConfig, EconomicalRetrievalMethodConfig -// - ImageUploaderInRetrievalTesting, retrieval-method-info, check-rerank-model - -// Mock RetrievalSettings to allow triggering onChange -vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSettings', () => ({ - default: ({ onChange }: { onChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void }) => { - return ( - <div data-testid="retrieval-settings-mock"> - <button data-testid="change-top-k" onClick={() => onChange({ top_k: 8 })}>Change Top K</button> - <button data-testid="change-score-threshold" onClick={() => onChange({ score_threshold: 0.9 })}>Change Score Threshold</button> - <button data-testid="change-score-enabled" onClick={() => onChange({ score_threshold_enabled: true })}>Change Score Enabled</button> - </div> - ) - }, -})) - -// ============================================================================ -// Mock Setup -// ============================================================================ - -// Mock next/navigation -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - push: vi.fn(), - replace: vi.fn(), - }), - usePathname: () => '/test', - useSearchParams: () => new URLSearchParams(), -})) - -// Mock use-context-selector -const mockDataset = { - id: 'dataset-1', - name: 'Test Dataset', - provider: 'vendor', - indexing_technique: 'high_quality' as const, - retrieval_model_dict: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_mode: undefined, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - weights: undefined, - top_k: 10, - score_threshold_enabled: false, - score_threshold: 0.5, - }, - is_multimodal: false, -} as Partial<DataSet> - -vi.mock('use-context-selector', () => ({ - useContext: vi.fn(() => ({ dataset: mockDataset })), - useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })), - createContext: vi.fn(() => ({})), -})) - -// Mock dataset detail context -vi.mock('@/context/dataset-detail', () => ({ - default: {}, - useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset })), - useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset }) => unknown) => - selector({ dataset: mockDataset as DataSet }), - ), -})) - -// Mock service hooks -const mockRecordsRefetch = vi.fn() -const mockHitTestingMutateAsync = vi.fn() -const mockExternalHitTestingMutateAsync = vi.fn() - -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetTestingRecords: vi.fn(() => ({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - })), -})) - -vi.mock('@/service/knowledge/use-hit-testing', () => ({ - useHitTesting: vi.fn(() => ({ - mutateAsync: mockHitTestingMutateAsync, - isPending: false, - })), - useExternalKnowledgeBaseHitTesting: vi.fn(() => ({ - mutateAsync: mockExternalHitTestingMutateAsync, - isPending: false, - })), -})) - -// Mock breakpoints hook -vi.mock('@/hooks/use-breakpoints', () => ({ - default: vi.fn(() => 'pc'), - MediaType: { - mobile: 'mobile', - pc: 'pc', - }, -})) - -// Mock timestamp hook -vi.mock('@/hooks/use-timestamp', () => ({ - default: vi.fn(() => ({ - formatTime: vi.fn((timestamp: number, _format: string) => new Date(timestamp * 1000).toISOString()), - })), -})) - -// Mock use-common to avoid QueryClient issues in nested hooks -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: vi.fn(() => ({ - data: { - file_size_limit: 10, - batch_count_limit: 5, - image_file_size_limit: 5, - }, - isLoading: false, - })), -})) - -// Store ref to ImageUploader onChange for testing -let mockImageUploaderOnChange: ((files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void) | null = null - -// Mock ImageUploaderInRetrievalTesting to capture onChange -vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ - default: ({ textArea, actionButton, onChange }: { - textArea: React.ReactNode - actionButton: React.ReactNode - onChange: (files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void - }) => { - mockImageUploaderOnChange = onChange - return ( - <div data-testid="image-uploader-mock"> - {textArea} - {actionButton} - <button - data-testid="trigger-image-change" - onClick={() => onChange([ - { - sourceUrl: 'http://example.com/new-image.png', - uploadedId: 'new-uploaded-id', - mimeType: 'image/png', - name: 'new-image.png', - size: 2000, - extension: 'png', - }, - ])} - > - Add Image - </button> - </div> - ) - }, -})) - -// Mock docLink hook -vi.mock('@/context/i18n', () => ({ - useDocLink: vi.fn(() => () => 'https://docs.example.com'), -})) - -// Mock provider context for retrieval method config -vi.mock('@/context/provider-context', () => ({ - useProviderContext: vi.fn(() => ({ - supportRetrievalMethods: [ - 'semantic_search', - 'full_text_search', - 'hybrid_search', - ], - })), -})) - -// Mock model list hook - include all exports used by child components -vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ - useModelList: vi.fn(() => ({ - data: [], - isLoading: false, - })), - useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({ - modelList: [], - defaultModel: undefined, - currentProvider: undefined, - currentModel: undefined, - })), - useModelListAndDefaultModel: vi.fn(() => ({ - modelList: [], - defaultModel: undefined, - })), - useCurrentProviderAndModel: vi.fn(() => ({ - currentProvider: undefined, - currentModel: undefined, - })), - useDefaultModel: vi.fn(() => ({ - defaultModel: undefined, - })), -})) - -// ============================================================================ -// Test Wrapper with QueryClientProvider -// ============================================================================ - -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - mutations: { - retry: false, - }, - }, -}) - -const TestWrapper = ({ children }: { children: ReactNode }) => { - const queryClient = createTestQueryClient() - return ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> - ) -} - -const renderWithProviders = (ui: React.ReactElement) => { - return render(ui, { wrapper: TestWrapper }) -} - -// ============================================================================ -// Test Factories -// ============================================================================ - -const createMockSegment = (overrides = {}) => ({ - id: 'segment-1', - document: { - id: 'doc-1', - data_source_type: 'upload_file', - name: 'test-document.pdf', - doc_type: 'book' as const, - }, - content: 'Test segment content', - sign_content: 'Test signed content', - position: 1, - word_count: 100, - tokens: 50, - keywords: ['test', 'keyword'], - hit_count: 5, - index_node_hash: 'hash-123', - answer: '', - ...overrides, -}) - -const createMockHitTesting = (overrides = {}): HitTesting => ({ - segment: createMockSegment() as HitTesting['segment'], - content: createMockSegment() as HitTesting['content'], - score: 0.85, - tsne_position: { x: 0.5, y: 0.5 }, - child_chunks: null, - files: [], - ...overrides, -}) - -const createMockChildChunk = (overrides = {}): HitTestingChildChunk => ({ - id: 'child-chunk-1', - content: 'Child chunk content', - position: 1, - score: 0.9, - ...overrides, -}) - -const createMockRecord = (overrides = {}): HitTestingRecord => ({ - id: 'record-1', - source: 'hit_testing', - source_app_id: 'app-1', - created_by_role: 'account', - created_by: 'user-1', - created_at: 1609459200, - queries: [ - { content: 'Test query', content_type: 'text_query', file_info: null }, - ], - ...overrides, -}) - -const createMockRetrievalConfig = (overrides = {}): RetrievalConfig => ({ - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_mode: undefined, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - weights: undefined, - top_k: 10, - score_threshold_enabled: false, - score_threshold: 0.5, - ...overrides, -} as RetrievalConfig) - -// ============================================================================ -// Utility Function Tests -// ============================================================================ - -describe('extensionToFileType', () => { - describe('PDF files', () => { - it('should return pdf type for pdf extension', () => { - expect(extensionToFileType('pdf')).toBe(FileAppearanceTypeEnum.pdf) - }) - }) - - describe('Word files', () => { - it('should return word type for doc extension', () => { - expect(extensionToFileType('doc')).toBe(FileAppearanceTypeEnum.word) - }) - - it('should return word type for docx extension', () => { - expect(extensionToFileType('docx')).toBe(FileAppearanceTypeEnum.word) - }) - }) - - describe('Markdown files', () => { - it('should return markdown type for md extension', () => { - expect(extensionToFileType('md')).toBe(FileAppearanceTypeEnum.markdown) - }) - - it('should return markdown type for mdx extension', () => { - expect(extensionToFileType('mdx')).toBe(FileAppearanceTypeEnum.markdown) - }) - - it('should return markdown type for markdown extension', () => { - expect(extensionToFileType('markdown')).toBe(FileAppearanceTypeEnum.markdown) - }) - }) - - describe('Excel files', () => { - it('should return excel type for csv extension', () => { - expect(extensionToFileType('csv')).toBe(FileAppearanceTypeEnum.excel) - }) - - it('should return excel type for xls extension', () => { - expect(extensionToFileType('xls')).toBe(FileAppearanceTypeEnum.excel) - }) - - it('should return excel type for xlsx extension', () => { - expect(extensionToFileType('xlsx')).toBe(FileAppearanceTypeEnum.excel) - }) - }) - - describe('Document files', () => { - it('should return document type for txt extension', () => { - expect(extensionToFileType('txt')).toBe(FileAppearanceTypeEnum.document) - }) - - it('should return document type for epub extension', () => { - expect(extensionToFileType('epub')).toBe(FileAppearanceTypeEnum.document) - }) - - it('should return document type for html extension', () => { - expect(extensionToFileType('html')).toBe(FileAppearanceTypeEnum.document) - }) - - it('should return document type for htm extension', () => { - expect(extensionToFileType('htm')).toBe(FileAppearanceTypeEnum.document) - }) - - it('should return document type for xml extension', () => { - expect(extensionToFileType('xml')).toBe(FileAppearanceTypeEnum.document) - }) - }) - - describe('PowerPoint files', () => { - it('should return ppt type for ppt extension', () => { - expect(extensionToFileType('ppt')).toBe(FileAppearanceTypeEnum.ppt) - }) - - it('should return ppt type for pptx extension', () => { - expect(extensionToFileType('pptx')).toBe(FileAppearanceTypeEnum.ppt) - }) - }) - - describe('Edge cases', () => { - it('should return custom type for unknown extension', () => { - expect(extensionToFileType('unknown')).toBe(FileAppearanceTypeEnum.custom) - }) - - it('should return custom type for empty string', () => { - expect(extensionToFileType('')).toBe(FileAppearanceTypeEnum.custom) - }) - }) -}) - -// ============================================================================ -// Score Component Tests -// ============================================================================ - -describe('Score', () => { - describe('Rendering', () => { - it('should render score with correct value', () => { - render(<Score value={0.85} />) - expect(screen.getByText('0.85')).toBeInTheDocument() - expect(screen.getByText('score')).toBeInTheDocument() - }) - - it('should render nothing when value is null', () => { - const { container } = render(<Score value={null} />) - expect(container.firstChild).toBeNull() - }) - - it('should render nothing when value is NaN', () => { - const { container } = render(<Score value={Number.NaN} />) - expect(container.firstChild).toBeNull() - }) - - it('should render nothing when value is 0', () => { - const { container } = render(<Score value={0} />) - expect(container.firstChild).toBeNull() - }) - }) - - describe('Props', () => { - it('should apply besideChunkName styles when prop is true', () => { - const { container } = render(<Score value={0.5} besideChunkName />) - const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('border-l-0') - }) - - it('should apply rounded styles when besideChunkName is false', () => { - const { container } = render(<Score value={0.5} besideChunkName={false} />) - const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('rounded-md') - }) - }) - - describe('Edge Cases', () => { - it('should display full score correctly', () => { - render(<Score value={1} />) - expect(screen.getByText('1.00')).toBeInTheDocument() - }) - - it('should display very small score correctly', () => { - render(<Score value={0.01} />) - expect(screen.getByText('0.01')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Mask Component Tests -// ============================================================================ - -describe('Mask', () => { - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = render(<Mask />) - expect(container.firstChild).toBeInTheDocument() - }) - - it('should have gradient background class', () => { - const { container } = render(<Mask />) - expect(container.firstChild).toHaveClass('bg-gradient-to-b') - }) - }) - - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render(<Mask className="custom-class" />) - expect(container.firstChild).toHaveClass('custom-class') - }) - }) -}) - -// ============================================================================ -// EmptyRecords Component Tests -// ============================================================================ - -describe('EmptyRecords', () => { - describe('Rendering', () => { - it('should render without crashing', () => { - render(<EmptyRecords />) - expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument() - }) - - it('should render history icon', () => { - const { container } = render(<EmptyRecords />) - const icon = container.querySelector('svg') - expect(icon).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// ResultItemMeta Component Tests -// ============================================================================ - -describe('ResultItemMeta', () => { - const defaultProps = { - labelPrefix: 'Chunk', - positionId: 1, - wordCount: 100, - score: 0.85, - } - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ResultItemMeta {...defaultProps} />) - expect(screen.getByText(/100/)).toBeInTheDocument() - }) - - it('should render score component', () => { - render(<ResultItemMeta {...defaultProps} />) - expect(screen.getByText('0.85')).toBeInTheDocument() - }) - - it('should render word count', () => { - render(<ResultItemMeta {...defaultProps} />) - expect(screen.getByText(/100/)).toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render(<ResultItemMeta {...defaultProps} className="custom-class" />) - expect(container.firstChild).toHaveClass('custom-class') - }) - - it('should handle different position IDs', () => { - render(<ResultItemMeta {...defaultProps} positionId={42} />) - // Position ID is passed to SegmentIndexTag - expect(screen.getByText(/42/)).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// ResultItemFooter Component Tests -// ============================================================================ - -describe('ResultItemFooter', () => { - const mockShowDetailModal = vi.fn() - const defaultProps = { - docType: FileAppearanceTypeEnum.pdf, - docTitle: 'Test Document.pdf', - showDetailModal: mockShowDetailModal, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ResultItemFooter {...defaultProps} />) - expect(screen.getByText('Test Document.pdf')).toBeInTheDocument() - }) - - it('should render open button', () => { - render(<ResultItemFooter {...defaultProps} />) - expect(screen.getByText(/open/i)).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call showDetailModal when open button is clicked', async () => { - render(<ResultItemFooter {...defaultProps} />) - - const openButton = screen.getByText(/open/i).parentElement - if (openButton) - fireEvent.click(openButton) - - expect(mockShowDetailModal).toHaveBeenCalledTimes(1) - }) - }) -}) - -// ============================================================================ -// ChildChunksItem Component Tests -// ============================================================================ - -describe('ChildChunksItem', () => { - const mockChildChunk = createMockChildChunk() - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ChildChunksItem payload={mockChildChunk} isShowAll={false} />) - expect(screen.getByText(/Child chunk content/)).toBeInTheDocument() - }) - - it('should render position identifier', () => { - render(<ChildChunksItem payload={mockChildChunk} isShowAll={false} />) - // The C- and position number are in the same element - expect(screen.getByText(/C-/)).toBeInTheDocument() - }) - - it('should render score', () => { - render(<ChildChunksItem payload={mockChildChunk} isShowAll={false} />) - expect(screen.getByText('0.90')).toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should apply line-clamp when isShowAll is false', () => { - const { container } = render(<ChildChunksItem payload={mockChildChunk} isShowAll={false} />) - expect(container.firstChild).toHaveClass('line-clamp-2') - }) - - it('should not apply line-clamp when isShowAll is true', () => { - const { container } = render(<ChildChunksItem payload={mockChildChunk} isShowAll={true} />) - expect(container.firstChild).not.toHaveClass('line-clamp-2') - }) - }) -}) - -// ============================================================================ -// ResultItem Component Tests -// ============================================================================ - -describe('ResultItem', () => { - const mockHitTesting = createMockHitTesting() - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ResultItem payload={mockHitTesting} />) - // Document name should be visible - expect(screen.getByText('test-document.pdf')).toBeInTheDocument() - }) - - it('should render score', () => { - render(<ResultItem payload={mockHitTesting} />) - expect(screen.getByText('0.85')).toBeInTheDocument() - }) - - it('should render document name in footer', () => { - render(<ResultItem payload={mockHitTesting} />) - expect(screen.getByText('test-document.pdf')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should open detail modal when clicked', async () => { - render(<ResultItem payload={mockHitTesting} />) - - const item = screen.getByText('test-document.pdf').closest('.cursor-pointer') - if (item) - fireEvent.click(item) - - await waitFor(() => { - expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() - }) - }) - }) - - describe('Parent-Child Retrieval', () => { - it('should render child chunks when present', () => { - const payloadWithChildren = createMockHitTesting({ - child_chunks: [createMockChildChunk()], - }) - - render(<ResultItem payload={payloadWithChildren} />) - expect(screen.getByText(/hitChunks/i)).toBeInTheDocument() - }) - - it('should toggle fold state when child chunks header is clicked', async () => { - const payloadWithChildren = createMockHitTesting({ - child_chunks: [createMockChildChunk()], - }) - - render(<ResultItem payload={payloadWithChildren} />) - - // Child chunks should be visible by default (not folded) - expect(screen.getByText(/Child chunk content/)).toBeInTheDocument() - - // Click to fold - const toggleButton = screen.getByText(/hitChunks/i).parentElement - if (toggleButton) { - fireEvent.click(toggleButton) - - await waitFor(() => { - expect(screen.queryByText(/Child chunk content/)).not.toBeInTheDocument() - }) - } - }) - }) - - describe('Keywords', () => { - it('should render keywords when present and no child chunks', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ keywords: ['keyword1', 'keyword2'] }), - child_chunks: null, - }) - - render(<ResultItem payload={payload} />) - expect(screen.getByText('keyword1')).toBeInTheDocument() - expect(screen.getByText('keyword2')).toBeInTheDocument() - }) - - it('should not render keywords when child chunks are present', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ keywords: ['keyword1'] }), - child_chunks: [createMockChildChunk()], - }) - - render(<ResultItem payload={payload} />) - expect(screen.queryByText('keyword1')).not.toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// ResultItemExternal Component Tests -// ============================================================================ - -describe('ResultItemExternal', () => { - const defaultProps = { - payload: { - content: 'External content', - title: 'External Title', - score: 0.75, - metadata: { - 'x-amz-bedrock-kb-source-uri': 'source-uri', - 'x-amz-bedrock-kb-data-source-id': 'data-source-id', - }, - }, - positionId: 1, - } - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ResultItemExternal {...defaultProps} />) - expect(screen.getByText('External content')).toBeInTheDocument() - }) - - it('should render title in footer', () => { - render(<ResultItemExternal {...defaultProps} />) - expect(screen.getByText('External Title')).toBeInTheDocument() - }) - - it('should render score', () => { - render(<ResultItemExternal {...defaultProps} />) - expect(screen.getByText('0.75')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should open detail modal when clicked', async () => { - render(<ResultItemExternal {...defaultProps} />) - - const item = screen.getByText('External content').closest('.cursor-pointer') - if (item) - fireEvent.click(item) - - await waitFor(() => { - expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() - }) - }) - }) -}) - -// ============================================================================ -// Textarea Component Tests -// ============================================================================ - -describe('Textarea', () => { - const mockHandleTextChange = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<Textarea text="" handleTextChange={mockHandleTextChange} />) - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - it('should display text value', () => { - render(<Textarea text="Test input" handleTextChange={mockHandleTextChange} />) - expect(screen.getByDisplayValue('Test input')).toBeInTheDocument() - }) - - it('should display character count', () => { - render(<Textarea text="Hello" handleTextChange={mockHandleTextChange} />) - expect(screen.getByText('5/200')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call handleTextChange when typing', async () => { - render(<Textarea text="" handleTextChange={mockHandleTextChange} />) - - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'New text' } }) - - expect(mockHandleTextChange).toHaveBeenCalled() - }) - }) - - describe('Validation', () => { - it('should show warning style when text exceeds 200 characters', () => { - const longText = 'a'.repeat(201) - const { container } = render(<Textarea text={longText} handleTextChange={mockHandleTextChange} />) - - expect(container.querySelector('.border-state-destructive-active')).toBeInTheDocument() - }) - - it('should show warning count when text exceeds 200 characters', () => { - const longText = 'a'.repeat(201) - render(<Textarea text={longText} handleTextChange={mockHandleTextChange} />) - - expect(screen.getByText('201/200')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Records Component Tests -// ============================================================================ - -describe('Records', () => { - const mockOnClickRecord = vi.fn() - const mockRecords = [ - createMockRecord({ id: 'record-1', created_at: 1609459200 }), - createMockRecord({ id: 'record-2', created_at: 1609545600 }), - ] - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - expect(screen.getByText(/queryContent/i)).toBeInTheDocument() - }) - - it('should render all records', () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - // Each record has "Test query" as content - expect(screen.getAllByText('Test query')).toHaveLength(2) - }) - - it('should render table headers', () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - expect(screen.getByText(/queryContent/i)).toBeInTheDocument() - expect(screen.getByText(/source/i)).toBeInTheDocument() - expect(screen.getByText(/time/i)).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onClickRecord when a record row is clicked', async () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - - // Find the table body row with the query content - const queryText = screen.getAllByText('Test query')[0] - const row = queryText.closest('tr') - if (row) - fireEvent.click(row) - - expect(mockOnClickRecord).toHaveBeenCalledTimes(1) - }) - - it('should toggle sort order when time header is clicked', async () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - - const timeHeader = screen.getByText(/time/i) - fireEvent.click(timeHeader) - - // Sort order should have toggled (default is desc, now should be asc) - // The records should be reordered - await waitFor(() => { - const rows = screen.getAllByText('Test query') - expect(rows).toHaveLength(2) - }) - }) - }) - - describe('Source Display', () => { - it('should display source correctly for hit_testing', () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - expect(screen.getAllByText(/retrieval test/i)).toHaveLength(2) - }) - - it('should display source correctly for app', () => { - const appRecords = [createMockRecord({ source: 'app' })] - render(<Records records={appRecords} onClickRecord={mockOnClickRecord} />) - expect(screen.getByText('app')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// ModifyExternalRetrievalModal Component Tests -// ============================================================================ - -describe('ModifyExternalRetrievalModal', () => { - const mockOnClose = vi.fn() - const mockOnSave = vi.fn() - const defaultProps = { - onClose: mockOnClose, - onSave: mockOnSave, - initialTopK: 4, - initialScoreThreshold: 0.5, - initialScoreThresholdEnabled: false, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - expect(screen.getByText(/settingTitle/i)).toBeInTheDocument() - }) - - it('should render cancel and save buttons', () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - expect(screen.getByText(/cancel/i)).toBeInTheDocument() - expect(screen.getByText(/save/i)).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onClose when cancel is clicked', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - fireEvent.click(screen.getByText(/cancel/i)) - - expect(mockOnClose).toHaveBeenCalledTimes(1) - }) - - it('should call onSave with settings when save is clicked', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith({ - top_k: 4, - score_threshold: 0.5, - score_threshold_enabled: false, - }) - }) - - it('should call onClose when close button is clicked', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - const closeButton = screen.getByRole('button', { name: '' }) - fireEvent.click(closeButton) - - expect(mockOnClose).toHaveBeenCalled() - }) - }) - - describe('Settings Change Handling', () => { - it('should update top_k when settings change', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - // Click the button to change top_k - fireEvent.click(screen.getByTestId('change-top-k')) - - // Save to verify the change - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - top_k: 8, - })) - }) - - it('should update score_threshold when settings change', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - // Click the button to change score_threshold - fireEvent.click(screen.getByTestId('change-score-threshold')) - - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - score_threshold: 0.9, - })) - }) - - it('should update score_threshold_enabled when settings change', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - // Click the button to change score_threshold_enabled - fireEvent.click(screen.getByTestId('change-score-enabled')) - - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - score_threshold_enabled: true, - })) - }) - - it('should call onClose after save', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - fireEvent.click(screen.getByText(/save/i)) - - // onClose should be called after onSave - expect(mockOnClose).toHaveBeenCalled() - }) - - it('should render with different initial values', () => { - render( - <ModifyExternalRetrievalModal - {...defaultProps} - initialTopK={10} - initialScoreThreshold={0.8} - initialScoreThresholdEnabled={true} - />, - ) - - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith({ - top_k: 10, - score_threshold: 0.8, - score_threshold_enabled: true, - }) - }) - - it('should handle partial settings changes', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - // Change only top_k - fireEvent.click(screen.getByTestId('change-top-k')) - - fireEvent.click(screen.getByText(/save/i)) - - // Should have updated top_k while keeping other values - expect(mockOnSave).toHaveBeenCalledWith({ - top_k: 8, - score_threshold: 0.5, - score_threshold_enabled: false, - }) - }) - - it('should handle multiple settings changes', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - // Change multiple settings - fireEvent.click(screen.getByTestId('change-top-k')) - fireEvent.click(screen.getByTestId('change-score-threshold')) - fireEvent.click(screen.getByTestId('change-score-enabled')) - - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith({ - top_k: 8, - score_threshold: 0.9, - score_threshold_enabled: true, - }) - }) - }) -}) - -// ============================================================================ -// ModifyRetrievalModal Component Tests -// ============================================================================ - -describe('ModifyRetrievalModal', () => { - const mockOnHide = vi.fn() - const mockOnSave = vi.fn() - const defaultProps = { - indexMethod: 'high_quality', - value: createMockRetrievalConfig(), - isShow: true, - onHide: mockOnHide, - onSave: mockOnSave, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing when isShow is true', () => { - const { container } = renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - // Modal should be rendered - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render nothing when isShow is false', () => { - const { container } = renderWithProviders(<ModifyRetrievalModal {...defaultProps} isShow={false} />) - expect(container.firstChild).toBeNull() - }) - - it('should render cancel and save buttons', () => { - renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThanOrEqual(2) - }) - - it('should render learn more link', () => { - renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - const link = screen.getByRole('link') - expect(link).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onHide when cancel button is clicked', async () => { - renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - - // Find cancel button (second to last button typically) - const buttons = screen.getAllByRole('button') - const cancelButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('cancel')) - if (cancelButton) - fireEvent.click(cancelButton) - - expect(mockOnHide).toHaveBeenCalledTimes(1) - }) - - it('should call onHide when close icon is clicked', async () => { - const { container } = renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - - // Find close button by its position (usually has the close icon) - const closeButton = container.querySelector('.cursor-pointer') - if (closeButton) - fireEvent.click(closeButton) - - expect(mockOnHide).toHaveBeenCalled() - }) - - it('should call onSave when save button is clicked', async () => { - renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - - const buttons = screen.getAllByRole('button') - const saveButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('save')) - if (saveButton) - fireEvent.click(saveButton) - - expect(mockOnSave).toHaveBeenCalled() - }) - }) - - describe('Index Method', () => { - it('should render for high_quality index method', () => { - const { container } = renderWithProviders(<ModifyRetrievalModal {...defaultProps} indexMethod="high_quality" />) - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render for economy index method', () => { - const { container } = renderWithProviders(<ModifyRetrievalModal {...defaultProps} indexMethod="economy" />) - expect(container.firstChild).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// ChunkDetailModal Component Tests -// ============================================================================ - -describe('ChunkDetailModal', () => { - const mockOnHide = vi.fn() - const mockPayload = createMockHitTesting() - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ChunkDetailModal payload={mockPayload} onHide={mockOnHide} />) - expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() - }) - - it('should render document name', () => { - render(<ChunkDetailModal payload={mockPayload} onHide={mockOnHide} />) - expect(screen.getByText('test-document.pdf')).toBeInTheDocument() - }) - - it('should render score', () => { - render(<ChunkDetailModal payload={mockPayload} onHide={mockOnHide} />) - expect(screen.getByText('0.85')).toBeInTheDocument() - }) - }) - - describe('Parent-Child Retrieval', () => { - it('should render child chunks section when present', () => { - const payloadWithChildren = createMockHitTesting({ - child_chunks: [createMockChildChunk()], - }) - - render(<ChunkDetailModal payload={payloadWithChildren} onHide={mockOnHide} />) - expect(screen.getByText(/hitChunks/i)).toBeInTheDocument() - }) - }) - - describe('Keywords', () => { - it('should render keywords section when present and no child chunks', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ keywords: ['keyword1', 'keyword2'] }), - child_chunks: null, - }) - - render(<ChunkDetailModal payload={payload} onHide={mockOnHide} />) - // Keywords should be rendered as tags - expect(screen.getByText('keyword1')).toBeInTheDocument() - expect(screen.getByText('keyword2')).toBeInTheDocument() - }) - }) - - describe('Q&A Mode', () => { - it('should render Q&A format when answer is present', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ - content: 'Question content', - answer: 'Answer content', - }), - }) - - render(<ChunkDetailModal payload={payload} onHide={mockOnHide} />) - expect(screen.getByText('Q')).toBeInTheDocument() - expect(screen.getByText('A')).toBeInTheDocument() - expect(screen.getByText('Question content')).toBeInTheDocument() - expect(screen.getByText('Answer content')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// QueryInput Component Tests -// ============================================================================ - -describe('QueryInput', () => { - const mockSetHitResult = vi.fn() - const mockSetExternalHitResult = vi.fn() - const mockOnUpdateList = vi.fn() - const mockSetQueries = vi.fn() - const mockOnClickRetrievalMethod = vi.fn() - const mockOnSubmit = vi.fn() - - const defaultProps = { - setHitResult: mockSetHitResult, - setExternalHitResult: mockSetExternalHitResult, - onUpdateList: mockOnUpdateList, - loading: false, - queries: [] as Query[], - setQueries: mockSetQueries, - isExternal: false, - onClickRetrievalMethod: mockOnClickRetrievalMethod, - retrievalConfig: createMockRetrievalConfig(), - isEconomy: false, - onSubmit: mockOnSubmit, - hitTestingMutation: mockHitTestingMutateAsync, - externalKnowledgeBaseHitTestingMutation: mockExternalHitTestingMutateAsync, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = render(<QueryInput {...defaultProps} />) - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render textarea', () => { - render(<QueryInput {...defaultProps} />) - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - it('should render testing button', () => { - render(<QueryInput {...defaultProps} />) - // Find button by role - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThan(0) - }) - }) - - describe('User Interactions', () => { - it('should update queries when text changes', async () => { - render(<QueryInput {...defaultProps} />) - - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'New query' } }) - - expect(mockSetQueries).toHaveBeenCalled() - }) - - it('should have disabled button when text is empty', () => { - render(<QueryInput {...defaultProps} />) - - // Find the primary/submit button - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).toBeDisabled() - }) - - it('should enable button when text is present', () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - render(<QueryInput {...defaultProps} queries={queries} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).not.toBeDisabled() - }) - - it('should disable button when text exceeds 200 characters', () => { - const longQuery: Query[] = [{ content: 'a'.repeat(201), content_type: 'text_query', file_info: null }] - render(<QueryInput {...defaultProps} queries={longQuery} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).toBeDisabled() - }) - - it('should show loading state on button when loading', () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - render(<QueryInput {...defaultProps} queries={queries} loading={true} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - // Button should have disabled styling classes - expect(submitButton).toHaveClass('disabled:btn-disabled') - }) - }) - - describe('External Mode', () => { - it('should render settings button for external mode', () => { - render(<QueryInput {...defaultProps} isExternal={true} />) - // In external mode, there should be a settings button - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThanOrEqual(2) - }) - - it('should open settings modal when settings button is clicked', async () => { - renderWithProviders(<QueryInput {...defaultProps} isExternal={true} />) - - // Find the settings button (not the submit button) - const buttons = screen.getAllByRole('button') - const settingsButton = buttons.find(btn => !btn.classList.contains('w-[88px]')) - if (settingsButton) - fireEvent.click(settingsButton) - - await waitFor(() => { - // The modal should render - look for more buttons after modal opens - expect(screen.getAllByRole('button').length).toBeGreaterThan(2) - }) - }) - }) - - describe('Non-External Mode', () => { - it('should render retrieval method selector for non-external mode', () => { - const { container } = renderWithProviders(<QueryInput {...defaultProps} isExternal={false} />) - // Should have the retrieval method display (a clickable div) - const methodSelector = container.querySelector('.cursor-pointer') - expect(methodSelector).toBeInTheDocument() - }) - - it('should call onClickRetrievalMethod when clicked', async () => { - const { container } = renderWithProviders(<QueryInput {...defaultProps} isExternal={false} />) - - // Find the method selector (the cursor-pointer div that's not a button) - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find(el => !el.closest('button')) - if (methodSelector) - fireEvent.click(methodSelector) - - expect(mockOnClickRetrievalMethod).toHaveBeenCalledTimes(1) - }) - }) - - describe('Submission', () => { - it('should call hitTestingMutation when submit is clicked for non-external', async () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - mockHitTestingMutateAsync.mockResolvedValue({ records: [] }) - - render(<QueryInput {...defaultProps} queries={queries} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockHitTestingMutateAsync).toHaveBeenCalled() - }) - }) - - it('should call externalKnowledgeBaseHitTestingMutation when submit is clicked for external', async () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - mockExternalHitTestingMutateAsync.mockResolvedValue({ records: [] }) - - render(<QueryInput {...defaultProps} queries={queries} isExternal={true} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockExternalHitTestingMutateAsync).toHaveBeenCalled() - }) - }) - - it('should call setHitResult and onUpdateList on successful non-external submission', async () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - const mockResponse = { query: { content: 'test' }, records: [] } - - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - options?.onSuccess?.(mockResponse) - return mockResponse - }) - - renderWithProviders(<QueryInput {...defaultProps} queries={queries} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockSetHitResult).toHaveBeenCalledWith(mockResponse) - expect(mockOnUpdateList).toHaveBeenCalled() - expect(mockOnSubmit).toHaveBeenCalled() - }) - }) - - it('should call setExternalHitResult and onUpdateList on successful external submission', async () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - const mockResponse = { query: { content: 'test' }, records: [] } - - mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => { - options?.onSuccess?.(mockResponse) - return mockResponse - }) - - renderWithProviders(<QueryInput {...defaultProps} queries={queries} isExternal={true} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockSetExternalHitResult).toHaveBeenCalledWith(mockResponse) - expect(mockOnUpdateList).toHaveBeenCalled() - }) - }) - }) - - describe('Image Queries', () => { - it('should handle queries with image_query type', () => { - const queriesWithImages: Query[] = [ - { content: 'Test query', content_type: 'text_query', file_info: null }, - { - content: 'http://example.com/image.png', - content_type: 'image_query', - file_info: { - id: 'file-1', - name: 'image.png', - size: 1000, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/image.png', - }, - }, - ] - - const { container } = renderWithProviders(<QueryInput {...defaultProps} queries={queriesWithImages} />) - expect(container.firstChild).toBeInTheDocument() - }) - - it('should disable button when images are not all uploaded', () => { - const queriesWithUnuploadedImages: Query[] = [ - { - content: 'http://example.com/image.png', - content_type: 'image_query', - file_info: { - id: '', // Empty id means not uploaded - name: 'image.png', - size: 1000, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/image.png', - }, - }, - ] - - renderWithProviders(<QueryInput {...defaultProps} queries={queriesWithUnuploadedImages} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).toBeDisabled() - }) - - it('should enable button when all images are uploaded', () => { - const queriesWithUploadedImages: Query[] = [ - { content: 'Test query', content_type: 'text_query', file_info: null }, - { - content: 'http://example.com/image.png', - content_type: 'image_query', - file_info: { - id: 'uploaded-file-1', - name: 'image.png', - size: 1000, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/image.png', - }, - }, - ] - - renderWithProviders(<QueryInput {...defaultProps} queries={queriesWithUploadedImages} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).not.toBeDisabled() - }) - - it('should call setQueries with image queries when images are added', async () => { - renderWithProviders(<QueryInput {...defaultProps} />) - - // Trigger image change via mock button - fireEvent.click(screen.getByTestId('trigger-image-change')) - - expect(mockSetQueries).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - content_type: 'image_query', - file_info: expect.objectContaining({ - name: 'new-image.png', - mime_type: 'image/png', - }), - }), - ]), - ) - }) - - it('should replace existing image queries when new images are added', async () => { - const existingQueries: Query[] = [ - { content: 'text', content_type: 'text_query', file_info: null }, - { - content: 'old-image', - content_type: 'image_query', - file_info: { - id: 'old-id', - name: 'old.png', - size: 500, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/old.png', - }, - }, - ] - - renderWithProviders(<QueryInput {...defaultProps} queries={existingQueries} />) - - // Trigger image change - should replace existing images - fireEvent.click(screen.getByTestId('trigger-image-change')) - - expect(mockSetQueries).toHaveBeenCalled() - }) - - it('should handle empty source URL in file', async () => { - // Mock the onChange to return file without sourceUrl - renderWithProviders(<QueryInput {...defaultProps} />) - - // The component should handle files with missing sourceUrl - if (mockImageUploaderOnChange) { - mockImageUploaderOnChange([ - { - sourceUrl: undefined, - uploadedId: 'id-1', - mimeType: 'image/png', - name: 'image.png', - size: 1000, - extension: 'png', - }, - ]) - } - - expect(mockSetQueries).toHaveBeenCalled() - }) - - it('should handle file without uploadedId', async () => { - renderWithProviders(<QueryInput {...defaultProps} />) - - if (mockImageUploaderOnChange) { - mockImageUploaderOnChange([ - { - sourceUrl: 'http://example.com/img.png', - uploadedId: undefined, - mimeType: 'image/png', - name: 'image.png', - size: 1000, - extension: 'png', - }, - ]) - } - - expect(mockSetQueries).toHaveBeenCalled() - }) - }) - - describe('Economy Mode', () => { - it('should use keyword search method when isEconomy is true', async () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - mockHitTestingMutateAsync.mockResolvedValue({ records: [] }) - - renderWithProviders(<QueryInput {...defaultProps} queries={queries} isEconomy={true} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockHitTestingMutateAsync).toHaveBeenCalledWith( - expect.objectContaining({ - retrieval_model: expect.objectContaining({ - search_method: 'keyword_search', - }), - }), - expect.anything(), - ) - }) - }) - }) - - describe('Text Query Handling', () => { - it('should add new text query when none exists', async () => { - renderWithProviders(<QueryInput {...defaultProps} queries={[]} />) - - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'New query' } }) - - expect(mockSetQueries).toHaveBeenCalledWith([ - expect.objectContaining({ - content: 'New query', - content_type: 'text_query', - }), - ]) - }) - - it('should update existing text query', async () => { - const existingQueries: Query[] = [{ content: 'Old query', content_type: 'text_query', file_info: null }] - renderWithProviders(<QueryInput {...defaultProps} queries={existingQueries} />) - - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'Updated query' } }) - - expect(mockSetQueries).toHaveBeenCalled() - }) - }) - - describe('External Settings Modal', () => { - it('should save external retrieval settings when modal saves', async () => { - renderWithProviders(<QueryInput {...defaultProps} isExternal={true} />) - - // Open settings modal - const buttons = screen.getAllByRole('button') - const settingsButton = buttons.find(btn => !btn.classList.contains('w-[88px]')) - if (settingsButton) - fireEvent.click(settingsButton) - - await waitFor(() => { - // Modal should be open - look for save button in modal - const allButtons = screen.getAllByRole('button') - expect(allButtons.length).toBeGreaterThan(2) - }) - - // Click save in modal - const saveButton = screen.getByText(/save/i) - fireEvent.click(saveButton) - - // Modal should close - await waitFor(() => { - const buttonsAfterClose = screen.getAllByRole('button') - // Should have fewer buttons after modal closes - expect(buttonsAfterClose.length).toBeLessThanOrEqual(screen.getAllByRole('button').length) - }) - }) - - it('should close settings modal when close button is clicked', async () => { - renderWithProviders(<QueryInput {...defaultProps} isExternal={true} />) - - // Open settings modal - const buttons = screen.getAllByRole('button') - const settingsButton = buttons.find(btn => !btn.classList.contains('w-[88px]')) - if (settingsButton) - fireEvent.click(settingsButton) - - await waitFor(() => { - const allButtons = screen.getAllByRole('button') - expect(allButtons.length).toBeGreaterThan(2) - }) - - // Click cancel - const cancelButton = screen.getByText(/cancel/i) - fireEvent.click(cancelButton) - - // Component should still be functional - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// HitTestingPage Component Tests -// ============================================================================ - -describe('HitTestingPage', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render page title', () => { - renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // Look for heading element - const heading = screen.getByRole('heading', { level: 1 }) - expect(heading).toBeInTheDocument() - }) - - it('should render records section', () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // The records section should be present - expect(container.querySelector('.flex-col')).toBeInTheDocument() - }) - - it('should render query input', () => { - renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - }) - - describe('Loading States', () => { - it('should show loading when records are loading', async () => { - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: undefined, - refetch: mockRecordsRefetch, - isLoading: true, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // Loading component should be visible - look for the loading animation - const loadingElement = container.querySelector('[class*="animate"]') || container.querySelector('.flex-1') - expect(loadingElement).toBeInTheDocument() - }) - }) - - describe('Empty States', () => { - it('should show empty records when no data', () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // EmptyRecords component should be rendered - check that the component is mounted - // The EmptyRecords has a specific structure with bg-workflow-process-bg class - const mainContainer = container.querySelector('.flex.h-full') - expect(mainContainer).toBeInTheDocument() - }) - }) - - describe('Records Display', () => { - it('should display records when data is present', async () => { - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [createMockRecord()], - total: 1, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - expect(screen.getByText('Test query')).toBeInTheDocument() - }) - }) - - describe('Pagination', () => { - it('should show pagination when total exceeds limit', async () => { - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: Array.from({ length: 10 }, (_, i) => createMockRecord({ id: `record-${i}` })), - total: 25, - page: 1, - limit: 10, - has_more: true, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // Pagination should be visible - look for pagination controls - const paginationElement = container.querySelector('[class*="pagination"]') || container.querySelector('nav') - expect(paginationElement || screen.getAllByText('Test query').length > 0).toBeTruthy() - }) - }) - - describe('Right Panel', () => { - it('should render right panel container', () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // The right panel should be present (on non-mobile) - const rightPanel = container.querySelector('.rounded-tl-2xl') - expect(rightPanel).toBeInTheDocument() - }) - }) - - describe('Retrieval Modal', () => { - it('should open retrieval modal when method is clicked', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Find the method selector (cursor-pointer div with the retrieval method) - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find(el => !el.closest('button') && !el.closest('tr')) - - // Verify we found a method selector to click - expect(methodSelector).toBeTruthy() - - if (methodSelector) - fireEvent.click(methodSelector) - - // The component should still be functional after the click - expect(container.firstChild).toBeInTheDocument() - }) - }) - - describe('Hit Results Display', () => { - it('should display hit results when hitResult has records', async () => { - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // The right panel should show empty state initially - expect(container.querySelector('.rounded-tl-2xl')).toBeInTheDocument() - }) - - it('should render loading skeleton when retrieval is in progress', async () => { - const { useHitTesting } = await import('@/service/knowledge/use-hit-testing') - vi.mocked(useHitTesting).mockReturnValue({ - mutateAsync: mockHitTestingMutateAsync, - isPending: true, - } as unknown as ReturnType<typeof useHitTesting>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Component should render without crashing - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render results when hit testing returns data', async () => { - // This test simulates the flow of getting hit results - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // The component should render the result display area - expect(container.querySelector('.bg-background-body')).toBeInTheDocument() - }) - }) - - describe('Record Interaction', () => { - it('should update queries when a record is clicked', async () => { - const mockRecord = createMockRecord({ - queries: [ - { content: 'Record query text', content_type: 'text_query', file_info: null }, - ], - }) - - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [mockRecord], - total: 1, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Find and click the record row - const recordText = screen.getByText('Record query text') - const row = recordText.closest('tr') - if (row) - fireEvent.click(row) - - // The query input should be updated - this causes re-render with new key - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - }) - - describe('External Dataset', () => { - it('should render external dataset UI when provider is external', async () => { - // Mock dataset with external provider - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Component should render - expect(container.firstChild).toBeInTheDocument() - }) - }) - - describe('Mobile View', () => { - it('should handle mobile breakpoint', async () => { - // Mock mobile breakpoint - const useBreakpoints = await import('@/hooks/use-breakpoints') - vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType<typeof useBreakpoints.default>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Component should still render - expect(container.firstChild).toBeInTheDocument() - }) - }) - - describe('useEffect for mobile panel', () => { - it('should update right panel visibility based on mobile state', async () => { - const useBreakpoints = await import('@/hooks/use-breakpoints') - - // First render with desktop - vi.mocked(useBreakpoints.default).mockReturnValue('pc' as unknown as ReturnType<typeof useBreakpoints.default>) - - const { rerender, container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - expect(container.firstChild).toBeInTheDocument() - - // Re-render with mobile - vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType<typeof useBreakpoints.default>) - - rerender( - <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}> - <HitTestingPage datasetId="dataset-1" /> - </QueryClientProvider>, - ) - - expect(container.firstChild).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Integration Tests -// ============================================================================ - -describe('Integration: Hit Testing Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockHitTestingMutateAsync.mockReset() - mockExternalHitTestingMutateAsync.mockReset() - }) - - it('should complete a full hit testing flow', async () => { - const mockResponse: HitTestingResponse = { - query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, - records: [createMockHitTesting()], - } - - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - options?.onSuccess?.(mockResponse) - return mockResponse - }) - - renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Type query - fireEvent.change(textarea, { target: { value: 'Test query' } }) - - // Find submit button by class - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).not.toBeDisabled() - }) - - it('should handle API error gracefully', async () => { - mockHitTestingMutateAsync.mockRejectedValue(new Error('API Error')) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Type query - fireEvent.change(textarea, { target: { value: 'Test query' } }) - - // Component should still be functional - check for the main container - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render hit results after successful submission', async () => { - const mockHitTestingRecord = createMockHitTesting() - const mockResponse: HitTestingResponse = { - query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, - records: [mockHitTestingRecord], - } - - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - // Call onSuccess synchronously to ensure state is updated - if (options?.onSuccess) - options.onSuccess(mockResponse) - return mockResponse - }) - - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container: _container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox to be rendered with timeout for CI environment - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Type query - fireEvent.change(textarea, { target: { value: 'Test query' } }) - - // Submit - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - // Wait for the mutation to complete - await waitFor( - () => { - expect(mockHitTestingMutateAsync).toHaveBeenCalled() - }, - { timeout: 3000 }, - ) - }) - - it('should render ResultItem components for non-external results', async () => { - const mockResponse: HitTestingResponse = { - query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, - records: [ - createMockHitTesting({ score: 0.95 }), - createMockHitTesting({ score: 0.85 }), - ], - } - - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - if (options?.onSuccess) - options.onSuccess(mockResponse) - return mockResponse - }) - - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container: _container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for component to be fully rendered with longer timeout - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Submit a query - fireEvent.change(textarea, { target: { value: 'Test query' } }) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - // Wait for mutation to complete with longer timeout - await waitFor( - () => { - expect(mockHitTestingMutateAsync).toHaveBeenCalled() - }, - { timeout: 3000 }, - ) - }) - - it('should render external results when dataset is external', async () => { - const mockExternalResponse = { - query: { content: 'test' }, - records: [ - { - title: 'External Result 1', - content: 'External content', - score: 0.9, - metadata: {}, - }, - ], - } - - mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => { - if (options?.onSuccess) - options.onSuccess(mockExternalResponse) - return mockExternalResponse - }) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Component should render - expect(container.firstChild).toBeInTheDocument() - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Type in textarea to verify component is functional - fireEvent.change(textarea, { target: { value: 'Test query' } }) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - // Verify component is still functional after submission - await waitFor( - () => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - }, - { timeout: 3000 }, - ) - }) -}) - -// ============================================================================ -// Drawer and Modal Interaction Tests -// ============================================================================ - -describe('Drawer and Modal Interactions', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should save retrieval config when ModifyRetrievalModal onSave is called', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Find and click the retrieval method selector to open the drawer - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), - ) - - if (methodSelector) { - fireEvent.click(methodSelector) - - await waitFor(() => { - // The drawer should open - verify container is still there - expect(container.firstChild).toBeInTheDocument() - }) - } - - // Component should still be functional - verify main container - expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument() - }) - - it('should close retrieval modal when onHide is called', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Open the modal first - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), - ) - - if (methodSelector) { - fireEvent.click(methodSelector) - } - - // Component should still be functional - expect(container.firstChild).toBeInTheDocument() - }) -}) - -// ============================================================================ -// renderHitResults Coverage Tests -// ============================================================================ - -describe('renderHitResults Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockHitTestingMutateAsync.mockReset() - }) - - it('should render hit results panel with records count', async () => { - const mockRecords = [ - createMockHitTesting({ score: 0.95 }), - createMockHitTesting({ score: 0.85 }), - ] - const mockResponse: HitTestingResponse = { - query: { content: 'test', tsne_position: { x: 0, y: 0 } }, - records: mockRecords, - } - - // Make mutation call onSuccess synchronously - mockHitTestingMutateAsync.mockImplementation(async (params, options) => { - // Simulate async behavior - await Promise.resolve() - if (options?.onSuccess) - options.onSuccess(mockResponse) - return mockResponse - }) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Enter query - fireEvent.change(textarea, { target: { value: 'test query' } }) - - // Submit - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - - if (submitButton) - fireEvent.click(submitButton) - - // Verify component is functional - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }) - }) - - it('should iterate through records and render ResultItem for each', async () => { - const mockRecords = [ - createMockHitTesting({ score: 0.9 }), - ] - - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - const response = { query: { content: 'test' }, records: mockRecords } - if (options?.onSuccess) - options.onSuccess(response) - return response - }) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'test' } }) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Drawer onSave Coverage Tests -// ============================================================================ - -describe('ModifyRetrievalModal onSave Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should update retrieval config when onSave is triggered', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Open the drawer - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), - ) - - if (methodSelector) { - fireEvent.click(methodSelector) - - // Wait for drawer to open - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }) - } - - // Verify component renders correctly - expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument() - }) - - it('should close modal after saving', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Open the drawer - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), - ) - - if (methodSelector) - fireEvent.click(methodSelector) - - // Component should still be rendered - expect(container.firstChild).toBeInTheDocument() - }) -}) - -// ============================================================================ -// Direct Component Coverage Tests -// ============================================================================ - -describe('HitTestingPage Internal Functions Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockHitTestingMutateAsync.mockReset() - mockExternalHitTestingMutateAsync.mockReset() - }) - - it('should trigger renderHitResults when mutation succeeds with records', async () => { - // Create mock hit testing records - const mockHitRecords = [ - createMockHitTesting({ score: 0.95 }), - createMockHitTesting({ score: 0.85 }), - ] - - const mockResponse: HitTestingResponse = { - query: { content: 'test query', tsne_position: { x: 0, y: 0 } }, - records: mockHitRecords, - } - - // Setup mutation to call onSuccess synchronously - mockHitTestingMutateAsync.mockImplementation((_params, options) => { - // Synchronously call onSuccess - if (options?.onSuccess) - options.onSuccess(mockResponse) - return Promise.resolve(mockResponse) - }) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Enter query and submit - fireEvent.change(textarea, { target: { value: 'test query' } }) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - - if (submitButton) { - fireEvent.click(submitButton) - } - - // Wait for state updates - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }, { timeout: 3000 }) - - // Verify mutation was called - expect(mockHitTestingMutateAsync).toHaveBeenCalled() - }) - - it('should handle retrieval config update via ModifyRetrievalModal', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Find and click retrieval method to open drawer - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), - ) - - if (methodSelector) { - fireEvent.click(methodSelector) - - // Wait for drawer content - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }) - - // Try to find save button in the drawer - const saveButtons = screen.queryAllByText(/save/i) - if (saveButtons.length > 0) { - fireEvent.click(saveButtons[0]) - } - } - - // Component should still work - expect(container.firstChild).toBeInTheDocument() - }) - - it('should show hit count in results panel after successful query', async () => { - const mockRecords = [createMockHitTesting()] - const mockResponse: HitTestingResponse = { - query: { content: 'test', tsne_position: { x: 0, y: 0 } }, - records: mockRecords, - } - - mockHitTestingMutateAsync.mockResolvedValue(mockResponse) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Submit a query - fireEvent.change(textarea, { target: { value: 'test' } }) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - - if (submitButton) - fireEvent.click(submitButton) - - // Verify the component renders - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }, { timeout: 3000 }) - }) -}) - -// ============================================================================ -// Memoization Tests -// ============================================================================ - -describe('Memoization', () => { - describe('Score component memoization', () => { - it('should be memoized', () => { - // Score is wrapped in React.memo - const { rerender } = render(<Score value={0.5} />) - - // Rerender with same props should not cause re-render - rerender(<Score value={0.5} />) - - expect(screen.getByText('0.50')).toBeInTheDocument() - }) - }) - - describe('Mask component memoization', () => { - it('should be memoized', () => { - const { rerender, container } = render(<Mask />) - - rerender(<Mask />) - - // Mask should still be rendered - expect(container.querySelector('.bg-gradient-to-b')).toBeInTheDocument() - }) - }) - - describe('EmptyRecords component memoization', () => { - it('should be memoized', () => { - const { rerender } = render(<EmptyRecords />) - - rerender(<EmptyRecords />) - - expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Accessibility Tests -// ============================================================================ - -describe('Accessibility', () => { - describe('Textarea', () => { - it('should have placeholder text', () => { - render(<Textarea text="" handleTextChange={vi.fn()} />) - expect(screen.getByPlaceholderText(/placeholder/i)).toBeInTheDocument() - }) - }) - - describe('Buttons', () => { - it('should have accessible buttons in QueryInput', () => { - render( - <QueryInput - setHitResult={vi.fn()} - setExternalHitResult={vi.fn()} - onUpdateList={vi.fn()} - loading={false} - queries={[]} - setQueries={vi.fn()} - isExternal={false} - onClickRetrievalMethod={vi.fn()} - retrievalConfig={createMockRetrievalConfig()} - isEconomy={false} - hitTestingMutation={vi.fn()} - externalKnowledgeBaseHitTestingMutation={vi.fn()} - />, - ) - expect(screen.getAllByRole('button').length).toBeGreaterThan(0) - }) - }) - - describe('Tables', () => { - it('should render table with proper structure', () => { - render( - <Records - records={[createMockRecord()]} - onClickRecord={vi.fn()} - />, - ) - expect(screen.getByRole('table')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Edge Cases -// ============================================================================ - -describe('Edge Cases', () => { - describe('Score with edge values', () => { - it('should handle very small scores', () => { - render(<Score value={0.001} />) - expect(screen.getByText('0.00')).toBeInTheDocument() - }) - - it('should handle scores close to 1', () => { - render(<Score value={0.999} />) - expect(screen.getByText('1.00')).toBeInTheDocument() - }) - }) - - describe('Records with various sources', () => { - it('should handle plugin source', () => { - const record = createMockRecord({ source: 'plugin' }) - render(<Records records={[record]} onClickRecord={vi.fn()} />) - expect(screen.getByText('plugin')).toBeInTheDocument() - }) - - it('should handle app source', () => { - const record = createMockRecord({ source: 'app' }) - render(<Records records={[record]} onClickRecord={vi.fn()} />) - expect(screen.getByText('app')).toBeInTheDocument() - }) - }) - - describe('ResultItem with various data', () => { - it('should handle empty keywords', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ keywords: [] }), - child_chunks: null, - }) - - render(<ResultItem payload={payload} />) - // Should not render keywords section - expect(screen.queryByText('keyword')).not.toBeInTheDocument() - }) - - it('should handle missing sign_content', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ sign_content: '', content: 'Fallback content' }), - }) - - render(<ResultItem payload={payload} />) - // The document name should still be visible - expect(screen.getByText('test-document.pdf')).toBeInTheDocument() - }) - }) - - describe('Records with images', () => { - it('should handle records with image queries', () => { - const recordWithImages = createMockRecord({ - queries: [ - { content: 'Text query', content_type: 'text_query', file_info: null }, - { - content: 'image-url', - content_type: 'image_query', - file_info: { - id: 'file-1', - name: 'image.png', - size: 1000, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/image.png', - }, - }, - ], - }) - - render(<Records records={[recordWithImages]} onClickRecord={vi.fn()} />) - expect(screen.getByText('Text query')).toBeInTheDocument() - }) - }) - - describe('ChunkDetailModal with files', () => { - it('should handle payload with image files', () => { - const payload = createMockHitTesting({ - files: [ - { - id: 'file-1', - name: 'image.png', - size: 1000, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/image.png', - }, - ], - }) - - render(<ChunkDetailModal payload={payload} onHide={vi.fn()} />) - expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/datasets/hit-testing/utils/__tests__/extension-to-file-type.spec.ts b/web/app/components/datasets/hit-testing/utils/__tests__/extension-to-file-type.spec.ts new file mode 100644 index 0000000000..fa493905a1 --- /dev/null +++ b/web/app/components/datasets/hit-testing/utils/__tests__/extension-to-file-type.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' +import { extensionToFileType } from '../extension-to-file-type' + +describe('extensionToFileType', () => { + // PDF extension + describe('pdf', () => { + it('should return pdf type when extension is pdf', () => { + expect(extensionToFileType('pdf')).toBe(FileAppearanceTypeEnum.pdf) + }) + }) + + // Word extensions + describe('word', () => { + it('should return word type when extension is doc', () => { + expect(extensionToFileType('doc')).toBe(FileAppearanceTypeEnum.word) + }) + + it('should return word type when extension is docx', () => { + expect(extensionToFileType('docx')).toBe(FileAppearanceTypeEnum.word) + }) + }) + + // Markdown extensions + describe('markdown', () => { + it('should return markdown type when extension is md', () => { + expect(extensionToFileType('md')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return markdown type when extension is mdx', () => { + expect(extensionToFileType('mdx')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return markdown type when extension is markdown', () => { + expect(extensionToFileType('markdown')).toBe(FileAppearanceTypeEnum.markdown) + }) + }) + + // Excel / CSV extensions + describe('excel', () => { + it('should return excel type when extension is csv', () => { + expect(extensionToFileType('csv')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return excel type when extension is xls', () => { + expect(extensionToFileType('xls')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return excel type when extension is xlsx', () => { + expect(extensionToFileType('xlsx')).toBe(FileAppearanceTypeEnum.excel) + }) + }) + + // Document extensions + describe('document', () => { + it('should return document type when extension is txt', () => { + expect(extensionToFileType('txt')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type when extension is epub', () => { + expect(extensionToFileType('epub')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type when extension is html', () => { + expect(extensionToFileType('html')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type when extension is htm', () => { + expect(extensionToFileType('htm')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type when extension is xml', () => { + expect(extensionToFileType('xml')).toBe(FileAppearanceTypeEnum.document) + }) + }) + + // PPT extensions + describe('ppt', () => { + it('should return ppt type when extension is ppt', () => { + expect(extensionToFileType('ppt')).toBe(FileAppearanceTypeEnum.ppt) + }) + + it('should return ppt type when extension is pptx', () => { + expect(extensionToFileType('pptx')).toBe(FileAppearanceTypeEnum.ppt) + }) + }) + + // Default / unknown extensions + describe('custom (default)', () => { + it('should return custom type when extension is empty string', () => { + expect(extensionToFileType('')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type when extension is unknown', () => { + expect(extensionToFileType('zip')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type when extension is uppercase (case-sensitive match)', () => { + expect(extensionToFileType('PDF')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type when extension is mixed case', () => { + expect(extensionToFileType('Docx')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type when extension has leading dot', () => { + expect(extensionToFileType('.pdf')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type when extension has whitespace', () => { + expect(extensionToFileType(' pdf ')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type for image-like extensions', () => { + expect(extensionToFileType('png')).toBe(FileAppearanceTypeEnum.custom) + expect(extensionToFileType('jpg')).toBe(FileAppearanceTypeEnum.custom) + }) + }) +}) diff --git a/web/app/components/datasets/list/datasets.spec.tsx b/web/app/components/datasets/list/__tests__/datasets.spec.tsx similarity index 97% rename from web/app/components/datasets/list/datasets.spec.tsx rename to web/app/components/datasets/list/__tests__/datasets.spec.tsx index 38843ab2e0..49bda88c8b 100644 --- a/web/app/components/datasets/list/datasets.spec.tsx +++ b/web/app/components/datasets/list/__tests__/datasets.spec.tsx @@ -4,22 +4,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import Datasets from './datasets' +import Datasets from '../datasets' -// Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), })) -// Mock ahooks -vi.mock('ahooks', async (importOriginal) => { - const actual = await importOriginal<typeof import('ahooks')>() - return { - ...actual, - useHover: () => false, - } -}) - // Mock useFormatTimeFromNow hook vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ @@ -64,7 +54,7 @@ vi.mock('@/context/app-context', () => ({ })) // Mock useDatasetCardState hook -vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({ +vi.mock('../dataset-card/hooks/use-dataset-card-state', () => ({ useDatasetCardState: () => ({ tags: [], setTags: vi.fn(), @@ -83,7 +73,7 @@ vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({ })) // Mock RenameDatasetModal -vi.mock('../rename-modal', () => ({ +vi.mock('../../rename-modal', () => ({ default: () => null, })) diff --git a/web/app/components/datasets/list/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx similarity index 91% rename from web/app/components/datasets/list/index.spec.tsx rename to web/app/components/datasets/list/__tests__/index.spec.tsx index ff48774c87..3e6d696c5b 100644 --- a/web/app/components/datasets/list/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -1,8 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import List from './index' +import List from '../index' -// Mock next/navigation const mockPush = vi.fn() const mockReplace = vi.fn() vi.mock('next/navigation', () => ({ @@ -12,17 +11,6 @@ vi.mock('next/navigation', () => ({ }), })) -// Mock ahooks -vi.mock('ahooks', async (importOriginal) => { - const actual = await importOriginal<typeof import('ahooks')>() - return { - ...actual, - useBoolean: () => [false, { toggle: vi.fn(), setTrue: vi.fn(), setFalse: vi.fn() }], - useDebounceFn: (fn: () => void) => ({ run: fn }), - useHover: () => false, - } -}) - // Mock app context vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ @@ -74,7 +62,6 @@ vi.mock('@/hooks/use-knowledge', () => ({ }), })) -// Mock service hooks vi.mock('@/service/knowledge/use-dataset', () => ({ useDatasetList: vi.fn(() => ({ data: { pages: [{ data: [] }] }, @@ -90,7 +77,7 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ })) // Mock Datasets component -vi.mock('./datasets', () => ({ +vi.mock('../datasets', () => ({ default: ({ tags, keywords, includeAll }: { tags: string[], keywords: string, includeAll: boolean }) => ( <div data-testid="datasets-component"> <span data-testid="tags">{tags.join(',')}</span> @@ -101,12 +88,12 @@ vi.mock('./datasets', () => ({ })) // Mock DatasetFooter component -vi.mock('./dataset-footer', () => ({ +vi.mock('../dataset-footer', () => ({ default: () => <footer data-testid="dataset-footer">Footer</footer>, })) // Mock ExternalAPIPanel component -vi.mock('../external-api/external-api-panel', () => ({ +vi.mock('../../external-api/external-api-panel', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="external-api-panel"> <button onClick={onClose}>Close Panel</button> @@ -257,7 +244,7 @@ describe('List', () => { // Clear module cache and re-import vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) @@ -292,7 +279,7 @@ describe('List', () => { })) vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) @@ -308,7 +295,7 @@ describe('List', () => { })) vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) @@ -324,7 +311,7 @@ describe('List', () => { })) vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) @@ -341,7 +328,7 @@ describe('List', () => { })) vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) @@ -358,7 +345,7 @@ describe('List', () => { })) vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ebe80e4686 --- /dev/null +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -0,0 +1,422 @@ +import type { DataSet } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { IndexingType } from '@/app/components/datasets/create/step-two' +import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import DatasetCardFooter from '../components/dataset-card-footer' +import Description from '../components/description' +import DatasetCard from '../index' +import OperationItem from '../operation-item' +import Operations from '../operations' + +// Mock external hooks only +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (timestamp: number) => { + const date = new Date(timestamp) + return `${date.toLocaleDateString()}` + }, + }), +})) + +const mockPush = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => boolean) => selector({ isCurrentWorkspaceDatasetOperator: false }), +})) + +vi.mock('../hooks/use-dataset-card-state', () => ({ + useDatasetCardState: () => ({ + tags: [], + setTags: vi.fn(), + modalState: { + showRenameModal: false, + showConfirmDelete: false, + confirmMessage: '', + }, + openRenameModal: vi.fn(), + closeRenameModal: vi.fn(), + closeConfirmDelete: vi.fn(), + handleExportPipeline: vi.fn(), + detectIsUsedByApp: vi.fn(), + onConfirmDelete: vi.fn(), + }), +})) + +vi.mock('../components/corner-labels', () => ({ + default: () => <div data-testid="corner-labels" />, +})) +vi.mock('../components/dataset-card-header', () => ({ + default: ({ dataset }: { dataset: DataSet }) => <div data-testid="card-header">{dataset.name}</div>, +})) +vi.mock('../components/dataset-card-modals', () => ({ + default: () => <div data-testid="card-modals" />, +})) +vi.mock('../components/tag-area', () => ({ + default: React.forwardRef<HTMLDivElement, { onClick: (e: React.MouseEvent) => void }>(({ onClick }, ref) => ( + <div ref={ref} data-testid="tag-area" onClick={onClick} /> + )), +})) +vi.mock('../components/operations-popover', () => ({ + default: () => <div data-testid="operations-popover" />, +})) + +// Factory function for DataSet mock data +const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ + id: 'dataset-1', + name: 'Test Dataset', + description: 'Test description', + provider: 'vendor', + permission: DatasetPermission.allTeamMembers, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingType.QUALIFIED, + embedding_available: true, + app_count: 5, + document_count: 10, + word_count: 1000, + created_at: 1609459200, + updated_at: 1609545600, + tags: [], + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + created_by: 'user-1', + doc_form: ChunkingMode.text, + total_available_documents: 10, + runtime_mode: 'general', + ...overrides, +} as DataSet) + +describe('DatasetCard Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Integration tests for Description component + describe('Description', () => { + describe('Rendering', () => { + it('should render description text from dataset', () => { + const dataset = createMockDataset({ description: 'My knowledge base' }) + render(<Description dataset={dataset} />) + expect(screen.getByText('My knowledge base')).toBeInTheDocument() + }) + + it('should set title attribute to description', () => { + const dataset = createMockDataset({ description: 'Hover text' }) + render(<Description dataset={dataset} />) + expect(screen.getByTitle('Hover text')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply opacity-30 when embedding_available is false', () => { + const dataset = createMockDataset({ embedding_available: false }) + render(<Description dataset={dataset} />) + const descDiv = screen.getByTitle(dataset.description) + expect(descDiv).toHaveClass('opacity-30') + }) + + it('should not apply opacity-30 when embedding_available is true', () => { + const dataset = createMockDataset({ embedding_available: true }) + render(<Description dataset={dataset} />) + const descDiv = screen.getByTitle(dataset.description) + expect(descDiv).not.toHaveClass('opacity-30') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty description', () => { + const dataset = createMockDataset({ description: '' }) + render(<Description dataset={dataset} />) + const descDiv = screen.getByTitle('') + expect(descDiv).toBeInTheDocument() + expect(descDiv).toHaveTextContent('') + }) + + it('should handle long description', () => { + const longDesc = 'X'.repeat(500) + const dataset = createMockDataset({ description: longDesc }) + render(<Description dataset={dataset} />) + expect(screen.getByText(longDesc)).toBeInTheDocument() + }) + }) + }) + + // Integration tests for DatasetCardFooter component + describe('DatasetCardFooter', () => { + describe('Rendering', () => { + it('should render document count', () => { + const dataset = createMockDataset({ document_count: 15, total_available_documents: 15 }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('15')).toBeInTheDocument() + }) + + it('should render app count for non-external provider', () => { + const dataset = createMockDataset({ app_count: 7, provider: 'vendor' }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('7')).toBeInTheDocument() + }) + + it('should render update time', () => { + const dataset = createMockDataset() + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText(/updated/i)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should show partial count when total_available_documents < document_count', () => { + const dataset = createMockDataset({ + document_count: 20, + total_available_documents: 12, + }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('12 / 20')).toBeInTheDocument() + }) + + it('should show single count when all documents are available', () => { + const dataset = createMockDataset({ + document_count: 20, + total_available_documents: 20, + }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('20')).toBeInTheDocument() + }) + + it('should not show app count when provider is external', () => { + const dataset = createMockDataset({ provider: 'external', app_count: 99 }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.queryByText('99')).not.toBeInTheDocument() + }) + + it('should have opacity when embedding_available is false', () => { + const dataset = createMockDataset({ embedding_available: false }) + const { container } = render(<DatasetCardFooter dataset={dataset} />) + const footer = container.firstChild as HTMLElement + expect(footer).toHaveClass('opacity-30') + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined total_available_documents', () => { + const dataset = createMockDataset({ + document_count: 10, + total_available_documents: undefined, + }) + render(<DatasetCardFooter dataset={dataset} />) + // total_available_documents defaults to 0, which is < 10 + expect(screen.getByText('0 / 10')).toBeInTheDocument() + }) + + it('should handle zero document count', () => { + const dataset = createMockDataset({ + document_count: 0, + total_available_documents: 0, + }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('should handle large numbers', () => { + const dataset = createMockDataset({ + document_count: 100000, + total_available_documents: 100000, + app_count: 50000, + }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('100000')).toBeInTheDocument() + expect(screen.getByText('50000')).toBeInTheDocument() + }) + }) + }) + + // Integration tests for OperationItem component + describe('OperationItem', () => { + const MockIcon = ({ className }: { className?: string }) => ( + <svg data-testid="mock-icon" className={className} /> + ) + + describe('Rendering', () => { + it('should render icon and name', () => { + render(<OperationItem Icon={MockIcon as never} name="Edit" />) + expect(screen.getByText('Edit')).toBeInTheDocument() + expect(screen.getByTestId('mock-icon')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call handleClick when clicked', () => { + const handleClick = vi.fn() + render(<OperationItem Icon={MockIcon as never} name="Delete" handleClick={handleClick} />) + + const item = screen.getByText('Delete').closest('div') + fireEvent.click(item!) + + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('should prevent default and stop propagation on click', () => { + const handleClick = vi.fn() + render(<OperationItem Icon={MockIcon as never} name="Action" handleClick={handleClick} />) + + const item = screen.getByText('Action').closest('div') + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventDefaultSpy = vi.spyOn(event, 'preventDefault') + const stopPropagationSpy = vi.spyOn(event, 'stopPropagation') + + item!.dispatchEvent(event) + + expect(preventDefaultSpy).toHaveBeenCalled() + expect(stopPropagationSpy).toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should not throw when handleClick is undefined', () => { + render(<OperationItem Icon={MockIcon as never} name="No handler" />) + const item = screen.getByText('No handler').closest('div') + expect(() => { + fireEvent.click(item!) + }).not.toThrow() + }) + + it('should handle empty name', () => { + render(<OperationItem Icon={MockIcon as never} name="" />) + expect(screen.getByTestId('mock-icon')).toBeInTheDocument() + }) + }) + }) + + // Integration tests for Operations component + describe('Operations', () => { + const defaultProps = { + showDelete: true, + showExportPipeline: true, + openRenameModal: vi.fn(), + handleExportPipeline: vi.fn(), + detectIsUsedByApp: vi.fn(), + } + + describe('Rendering', () => { + it('should always render edit operation', () => { + render(<Operations {...defaultProps} />) + expect(screen.getByText(/operation\.edit/)).toBeInTheDocument() + }) + + it('should render export pipeline when showExportPipeline is true', () => { + render(<Operations {...defaultProps} showExportPipeline={true} />) + expect(screen.getByText(/exportPipeline/)).toBeInTheDocument() + }) + + it('should not render export pipeline when showExportPipeline is false', () => { + render(<Operations {...defaultProps} showExportPipeline={false} />) + expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument() + }) + + it('should render delete when showDelete is true', () => { + render(<Operations {...defaultProps} showDelete={true} />) + expect(screen.getByText(/operation\.delete/)).toBeInTheDocument() + }) + + it('should not render delete when showDelete is false', () => { + render(<Operations {...defaultProps} showDelete={false} />) + expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call openRenameModal when edit is clicked', () => { + const openRenameModal = vi.fn() + render(<Operations {...defaultProps} openRenameModal={openRenameModal} />) + + const editItem = screen.getByText(/operation\.edit/).closest('div') + fireEvent.click(editItem!) + + expect(openRenameModal).toHaveBeenCalledTimes(1) + }) + + it('should call handleExportPipeline when export is clicked', () => { + const handleExportPipeline = vi.fn() + render(<Operations {...defaultProps} handleExportPipeline={handleExportPipeline} />) + + const exportItem = screen.getByText(/exportPipeline/).closest('div') + fireEvent.click(exportItem!) + + expect(handleExportPipeline).toHaveBeenCalledTimes(1) + }) + + it('should call detectIsUsedByApp when delete is clicked', () => { + const detectIsUsedByApp = vi.fn() + render(<Operations {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />) + + const deleteItem = screen.getByText(/operation\.delete/).closest('div') + fireEvent.click(deleteItem!) + + expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should render only edit when both showDelete and showExportPipeline are false', () => { + render(<Operations {...defaultProps} showDelete={false} showExportPipeline={false} />) + expect(screen.getByText(/operation\.edit/)).toBeInTheDocument() + expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument() + expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument() + }) + + it('should render divider before delete section when showDelete is true', () => { + const { container } = render(<Operations {...defaultProps} showDelete={true} />) + expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument() + }) + + it('should not render divider when showDelete is false', () => { + const { container } = render(<Operations {...defaultProps} showDelete={false} />) + expect(container.querySelector('.bg-divider-subtle')).toBeNull() + }) + }) + }) +}) + +describe('DatasetCard Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render and navigate to documents when clicked', () => { + const dataset = createMockDataset() + render(<DatasetCard dataset={dataset} />) + + fireEvent.click(screen.getByText('Test Dataset')) + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents') + }) + + it('should navigate to hitTesting for external provider', () => { + const dataset = createMockDataset({ provider: 'external' }) + render(<DatasetCard dataset={dataset} />) + + fireEvent.click(screen.getByText('Test Dataset')) + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting') + }) + + it('should navigate to pipeline for unpublished pipeline', () => { + const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false }) + render(<DatasetCard dataset={dataset} />) + + fireEvent.click(screen.getByText('Test Dataset')) + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline') + }) + + it('should stop propagation when tag area is clicked', () => { + const dataset = createMockDataset() + render(<DatasetCard dataset={dataset} />) + + const tagArea = screen.getByTestId('tag-area') + fireEvent.click(tagArea) + // Tag area click should not trigger card navigation + expect(mockPush).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/datasets/list/dataset-card/operation-item.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx similarity index 98% rename from web/app/components/datasets/list/dataset-card/operation-item.spec.tsx rename to web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx index 848d83c416..335f193146 100644 --- a/web/app/components/datasets/list/dataset-card/operation-item.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx @@ -1,7 +1,7 @@ import { RiEditLine } from '@remixicon/react' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import OperationItem from './operation-item' +import OperationItem from '../operation-item' describe('OperationItem', () => { const defaultProps = { diff --git a/web/app/components/datasets/list/dataset-card/operations.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/operations.spec.tsx rename to web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx index 66a799baa5..edb54cba0c 100644 --- a/web/app/components/datasets/list/dataset-card/operations.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import Operations from './operations' +import Operations from '../operations' describe('Operations', () => { const defaultProps = { diff --git a/web/app/components/datasets/list/dataset-card/components/corner-labels.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/components/corner-labels.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx index 00ee1f85f9..7fff6f4dc1 100644 --- a/web/app/components/datasets/list/dataset-card/components/corner-labels.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import CornerLabels from './corner-labels' +import CornerLabels from '../corner-labels' describe('CornerLabels', () => { const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-footer.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/components/dataset-card-footer.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-footer.spec.tsx index 6ca0363097..e170de2340 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-footer.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import DatasetCardFooter from './dataset-card-footer' +import DatasetCardFooter from '../dataset-card-footer' // Mock the useFormatTimeFromNow hook vi.mock('@/hooks/use-format-time-from-now', () => ({ diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx index c7121287b3..7d1a239f43 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import DatasetCardHeader from './dataset-card-header' +import DatasetCardHeader from '../dataset-card-header' // Mock AppIcon component to avoid emoji-mart initialization issues vi.mock('@/app/components/base/app-icon', () => ({ diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx similarity index 98% rename from web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx index 607830661d..e3e4a70936 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx @@ -3,10 +3,10 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import DatasetCardModals from './dataset-card-modals' +import DatasetCardModals from '../dataset-card-modals' // Mock RenameDatasetModal since it's from a different feature folder -vi.mock('../../../rename-modal', () => ({ +vi.mock('../../../../rename-modal', () => ({ default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: () => void }) => ( show ? ( diff --git a/web/app/components/datasets/list/dataset-card/components/description.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/description.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/components/description.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/description.spec.tsx index e7599f31e3..1a4d6c57cc 100644 --- a/web/app/components/datasets/list/dataset-card/components/description.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/description.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import Description from './description' +import Description from '../description' describe('Description', () => { const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ diff --git a/web/app/components/datasets/list/dataset-card/components/operations-popover.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-popover.spec.tsx similarity index 97% rename from web/app/components/datasets/list/dataset-card/components/operations-popover.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/operations-popover.spec.tsx index bf8daae0c3..d79bf1aaa8 100644 --- a/web/app/components/datasets/list/dataset-card/components/operations-popover.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-popover.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import OperationsPopover from './operations-popover' +import OperationsPopover from '../operations-popover' describe('OperationsPopover', () => { const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ @@ -63,7 +63,6 @@ describe('OperationsPopover', () => { it('should show delete option when not workspace dataset operator', () => { render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={false} />) - // Click to open popover const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) @@ -75,7 +74,6 @@ describe('OperationsPopover', () => { it('should hide delete option when is workspace dataset operator', () => { render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={true} />) - // Click to open popover const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) @@ -87,7 +85,6 @@ describe('OperationsPopover', () => { const dataset = createMockDataset({ runtime_mode: 'rag_pipeline' }) render(<OperationsPopover {...defaultProps} dataset={dataset} />) - // Click to open popover const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) @@ -99,7 +96,6 @@ describe('OperationsPopover', () => { const dataset = createMockDataset({ runtime_mode: 'general' }) render(<OperationsPopover {...defaultProps} dataset={dataset} />) - // Click to open popover const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) diff --git a/web/app/components/datasets/list/dataset-card/components/tag-area.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/tag-area.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/components/tag-area.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/tag-area.spec.tsx index d55628bd6c..2858469cdb 100644 --- a/web/app/components/datasets/list/dataset-card/components/tag-area.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/tag-area.spec.tsx @@ -5,7 +5,7 @@ import { useRef } from 'react' import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import TagArea from './tag-area' +import TagArea from '../tag-area' // Mock TagSelector as it's a complex component from base vi.mock('@/app/components/base/tag-management/selector', () => ({ diff --git a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.spec.ts b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts similarity index 99% rename from web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.spec.ts rename to web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts index 6eda57ae5b..63ac30630e 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.spec.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts @@ -3,16 +3,14 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import { useDatasetCardState } from './use-dataset-card-state' +import { useDatasetCardState } from '../use-dataset-card-state' -// Mock Toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), }, })) -// Mock service hooks const mockCheckUsage = vi.fn() const mockDeleteDataset = vi.fn() const mockExportPipeline = vi.fn() diff --git a/web/app/components/datasets/list/dataset-card/index.spec.tsx b/web/app/components/datasets/list/dataset-card/index.spec.tsx deleted file mode 100644 index dd27eaa262..0000000000 --- a/web/app/components/datasets/list/dataset-card/index.spec.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import type { DataSet } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { IndexingType } from '@/app/components/datasets/create/step-two' -import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import { RETRIEVE_METHOD } from '@/types/app' -import DatasetCard from './index' - -// Mock next/navigation -const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: mockPush }), -})) - -// Mock ahooks useHover -vi.mock('ahooks', async (importOriginal) => { - const actual = await importOriginal<typeof import('ahooks')>() - return { - ...actual, - useHover: () => false, - } -}) - -// Mock app context -vi.mock('@/context/app-context', () => ({ - useSelector: () => false, -})) - -// Mock the useDatasetCardState hook -vi.mock('./hooks/use-dataset-card-state', () => ({ - useDatasetCardState: () => ({ - tags: [], - setTags: vi.fn(), - modalState: { - showRenameModal: false, - showConfirmDelete: false, - confirmMessage: '', - }, - openRenameModal: vi.fn(), - closeRenameModal: vi.fn(), - closeConfirmDelete: vi.fn(), - handleExportPipeline: vi.fn(), - detectIsUsedByApp: vi.fn(), - onConfirmDelete: vi.fn(), - }), -})) - -// Mock the RenameDatasetModal -vi.mock('../../rename-modal', () => ({ - default: () => null, -})) - -// Mock useFormatTimeFromNow hook -vi.mock('@/hooks/use-format-time-from-now', () => ({ - useFormatTimeFromNow: () => ({ - formatTimeFromNow: (timestamp: number) => { - const date = new Date(timestamp) - return date.toLocaleDateString() - }, - }), -})) - -// Mock useKnowledge hook -vi.mock('@/hooks/use-knowledge', () => ({ - useKnowledge: () => ({ - formatIndexingTechniqueAndMethod: () => 'High Quality', - }), -})) - -describe('DatasetCard', () => { - const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ - id: 'dataset-1', - name: 'Test Dataset', - description: 'Test description', - provider: 'vendor', - permission: DatasetPermission.allTeamMembers, - data_source_type: DataSourceType.FILE, - indexing_technique: IndexingType.QUALIFIED, - embedding_available: true, - app_count: 5, - document_count: 10, - word_count: 1000, - created_at: 1609459200, - updated_at: 1609545600, - tags: [], - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - created_by: 'user-1', - doc_form: ChunkingMode.text, - runtime_mode: 'general', - is_published: true, - total_available_documents: 10, - icon_info: { - icon: '📙', - icon_type: 'emoji' as const, - icon_background: '#FFF4ED', - icon_url: '', - }, - retrieval_model_dict: { - search_method: RETRIEVE_METHOD.semantic, - }, - author_name: 'Test User', - ...overrides, - } as DataSet) - - const defaultProps = { - dataset: createMockDataset(), - onSuccess: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<DatasetCard {...defaultProps} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - - it('should render dataset name', () => { - const dataset = createMockDataset({ name: 'Custom Dataset Name' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument() - }) - - it('should render dataset description', () => { - const dataset = createMockDataset({ description: 'Custom Description' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Custom Description')).toBeInTheDocument() - }) - - it('should render document count', () => { - render(<DatasetCard {...defaultProps} />) - expect(screen.getByText('10')).toBeInTheDocument() - }) - - it('should render app count', () => { - render(<DatasetCard {...defaultProps} />) - expect(screen.getByText('5')).toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should handle external provider', () => { - const dataset = createMockDataset({ provider: 'external' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - - it('should handle rag_pipeline runtime mode', () => { - const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should navigate to documents page on click for regular dataset', () => { - const dataset = createMockDataset({ provider: 'vendor' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - - const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]') - fireEvent.click(card!) - - expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents') - }) - - it('should navigate to hitTesting page on click for external provider', () => { - const dataset = createMockDataset({ provider: 'external' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - - const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]') - fireEvent.click(card!) - - expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting') - }) - - it('should navigate to pipeline page when pipeline is unpublished', () => { - const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - - const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]') - fireEvent.click(card!) - - expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline') - }) - }) - - describe('Styles', () => { - it('should have correct card styling', () => { - render(<DatasetCard {...defaultProps} />) - const card = screen.getByText('Test Dataset').closest('.group') - expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl') - }) - - it('should have data-disable-nprogress attribute', () => { - render(<DatasetCard {...defaultProps} />) - const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]') - expect(card).toHaveAttribute('data-disable-nprogress', 'true') - }) - }) - - describe('Edge Cases', () => { - it('should handle dataset without description', () => { - const dataset = createMockDataset({ description: '' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - - it('should handle embedding not available', () => { - const dataset = createMockDataset({ embedding_available: false }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - - it('should handle undefined onSuccess', () => { - render(<DatasetCard dataset={createMockDataset()} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - }) - - describe('Tag Area Click', () => { - it('should stop propagation and prevent default when tag area is clicked', () => { - render(<DatasetCard {...defaultProps} />) - - // Find tag area element (it's inside the card) - const tagAreaWrapper = document.querySelector('[class*="px-3"]') - if (tagAreaWrapper) { - const stopPropagationSpy = vi.fn() - const preventDefaultSpy = vi.fn() - - const clickEvent = new MouseEvent('click', { bubbles: true }) - Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy }) - Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy }) - - tagAreaWrapper.dispatchEvent(clickEvent) - - expect(stopPropagationSpy).toHaveBeenCalled() - expect(preventDefaultSpy).toHaveBeenCalled() - } - }) - - it('should not navigate when clicking on tag area', () => { - render(<DatasetCard {...defaultProps} />) - - // Click on tag area should not trigger card navigation - const tagArea = document.querySelector('[class*="px-3"]') - if (tagArea) { - fireEvent.click(tagArea) - // mockPush should NOT be called when clicking tag area - // (stopPropagation prevents it from reaching the card click handler) - } - }) - }) -}) diff --git a/web/app/components/datasets/list/dataset-footer/index.spec.tsx b/web/app/components/datasets/list/dataset-footer/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/datasets/list/dataset-footer/index.spec.tsx rename to web/app/components/datasets/list/dataset-footer/__tests__/index.spec.tsx index 1bea093c91..f95eb4c6b6 100644 --- a/web/app/components/datasets/list/dataset-footer/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-footer/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import DatasetFooter from './index' +import DatasetFooter from '../index' describe('DatasetFooter', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/list/new-dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/new-dataset-card/__tests__/index.spec.tsx new file mode 100644 index 0000000000..133e97871f --- /dev/null +++ b/web/app/components/datasets/list/new-dataset-card/__tests__/index.spec.tsx @@ -0,0 +1,134 @@ +import { RiAddLine } from '@remixicon/react' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CreateAppCard from '../index' +import Option from '../option' + +describe('New Dataset Card Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Integration tests for Option component + describe('Option', () => { + describe('Rendering', () => { + it('should render a link with text and icon', () => { + render(<Option Icon={RiAddLine} text="Create" href="/create" />) + const link = screen.getByRole('link') + expect(link).toBeInTheDocument() + expect(screen.getByText('Create')).toBeInTheDocument() + }) + + it('should render icon with correct sizing class', () => { + const { container } = render(<Option Icon={RiAddLine} text="Test" href="/test" />) + const icon = container.querySelector('.h-4.w-4') + expect(icon).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should set correct href on the link', () => { + render(<Option Icon={RiAddLine} text="Go" href="/datasets/create" />) + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', '/datasets/create') + }) + + it('should render different text based on props', () => { + render(<Option Icon={RiAddLine} text="Custom Text" href="/path" />) + expect(screen.getByText('Custom Text')).toBeInTheDocument() + }) + + it('should render different href based on props', () => { + render(<Option Icon={RiAddLine} text="Link" href="/custom-path" />) + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', '/custom-path') + }) + }) + + describe('Styles', () => { + it('should have correct link styling', () => { + render(<Option Icon={RiAddLine} text="Styled" href="/style" />) + const link = screen.getByRole('link') + expect(link).toHaveClass('flex', 'w-full', 'items-center', 'gap-x-2', 'rounded-lg') + }) + + it('should have text span with correct styling', () => { + render(<Option Icon={RiAddLine} text="Text Style" href="/s" />) + const textSpan = screen.getByText('Text Style') + expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty text', () => { + render(<Option Icon={RiAddLine} text="" href="/empty" />) + const link = screen.getByRole('link') + expect(link).toBeInTheDocument() + }) + + it('should handle long text', () => { + const longText = 'Z'.repeat(200) + render(<Option Icon={RiAddLine} text={longText} href="/long" />) + expect(screen.getByText(longText)).toBeInTheDocument() + }) + }) + }) + + // Integration tests for CreateAppCard component + describe('CreateAppCard', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render(<CreateAppCard />) + // All 3 options should be visible + const links = screen.getAllByRole('link') + expect(links).toHaveLength(3) + }) + + it('should render the create dataset option', () => { + render(<CreateAppCard />) + expect(screen.getByText(/createDataset/)).toBeInTheDocument() + }) + + it('should render the create from pipeline option', () => { + render(<CreateAppCard />) + expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument() + }) + + it('should render the connect dataset option', () => { + render(<CreateAppCard />) + expect(screen.getByText(/connectDataset/)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should have correct href for create dataset', () => { + render(<CreateAppCard />) + const links = screen.getAllByRole('link') + const createLink = links.find(link => link.getAttribute('href') === '/datasets/create') + expect(createLink).toBeDefined() + }) + + it('should have correct href for create from pipeline', () => { + render(<CreateAppCard />) + const links = screen.getAllByRole('link') + const pipelineLink = links.find(link => link.getAttribute('href') === '/datasets/create-from-pipeline') + expect(pipelineLink).toBeDefined() + }) + + it('should have correct href for connect dataset', () => { + render(<CreateAppCard />) + const links = screen.getAllByRole('link') + const connectLink = links.find(link => link.getAttribute('href') === '/datasets/connect') + expect(connectLink).toBeDefined() + }) + }) + + describe('Styles', () => { + it('should have correct container styling', () => { + const { container } = render(<CreateAppCard />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'flex-col', 'rounded-xl') + }) + }) + }) +}) diff --git a/web/app/components/datasets/list/new-dataset-card/option.spec.tsx b/web/app/components/datasets/list/new-dataset-card/__tests__/option.spec.tsx similarity index 98% rename from web/app/components/datasets/list/new-dataset-card/option.spec.tsx rename to web/app/components/datasets/list/new-dataset-card/__tests__/option.spec.tsx index 0aefaa261e..3f0d1f952c 100644 --- a/web/app/components/datasets/list/new-dataset-card/option.spec.tsx +++ b/web/app/components/datasets/list/new-dataset-card/__tests__/option.spec.tsx @@ -1,7 +1,7 @@ import { RiAddLine } from '@remixicon/react' import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Option from './option' +import Option from '../option' describe('Option', () => { const defaultProps = { diff --git a/web/app/components/datasets/list/new-dataset-card/index.spec.tsx b/web/app/components/datasets/list/new-dataset-card/index.spec.tsx deleted file mode 100644 index 2ce66e134b..0000000000 --- a/web/app/components/datasets/list/new-dataset-card/index.spec.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import CreateAppCard from './index' - -describe('CreateAppCard', () => { - describe('Rendering', () => { - it('should render without crashing', () => { - render(<CreateAppCard />) - expect(screen.getAllByRole('link')).toHaveLength(3) - }) - - it('should render create dataset option', () => { - render(<CreateAppCard />) - expect(screen.getByText(/createDataset/)).toBeInTheDocument() - }) - - it('should render create from pipeline option', () => { - render(<CreateAppCard />) - expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument() - }) - - it('should render connect dataset option', () => { - render(<CreateAppCard />) - expect(screen.getByText(/connectDataset/)).toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should have correct displayName', () => { - expect(CreateAppCard.displayName).toBe('CreateAppCard') - }) - }) - - describe('Links', () => { - it('should have correct href for create dataset', () => { - render(<CreateAppCard />) - const links = screen.getAllByRole('link') - expect(links[0]).toHaveAttribute('href', '/datasets/create') - }) - - it('should have correct href for create from pipeline', () => { - render(<CreateAppCard />) - const links = screen.getAllByRole('link') - expect(links[1]).toHaveAttribute('href', '/datasets/create-from-pipeline') - }) - - it('should have correct href for connect dataset', () => { - render(<CreateAppCard />) - const links = screen.getAllByRole('link') - expect(links[2]).toHaveAttribute('href', '/datasets/connect') - }) - }) - - describe('Styles', () => { - it('should have correct card styling', () => { - const { container } = render(<CreateAppCard />) - const card = container.firstChild as HTMLElement - expect(card).toHaveClass('h-[190px]', 'flex', 'flex-col', 'rounded-xl') - }) - - it('should have border separator for connect option', () => { - const { container } = render(<CreateAppCard />) - const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]') - expect(borderDiv).toBeInTheDocument() - }) - }) - - describe('Icons', () => { - it('should render three icons for three options', () => { - const { container } = render(<CreateAppCard />) - // Each option has an icon - const icons = container.querySelectorAll('svg') - expect(icons.length).toBeGreaterThanOrEqual(3) - }) - }) -}) diff --git a/web/app/components/datasets/metadata/add-metadata-button.spec.tsx b/web/app/components/datasets/metadata/__tests__/add-metadata-button.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/add-metadata-button.spec.tsx rename to web/app/components/datasets/metadata/__tests__/add-metadata-button.spec.tsx index 642b8b71ec..2dbfb6febe 100644 --- a/web/app/components/datasets/metadata/add-metadata-button.spec.tsx +++ b/web/app/components/datasets/metadata/__tests__/add-metadata-button.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import AddedMetadataButton from './add-metadata-button' +import AddedMetadataButton from '../add-metadata-button' describe('AddedMetadataButton', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/metadata/base/date-picker.spec.tsx b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx similarity index 99% rename from web/app/components/datasets/metadata/base/date-picker.spec.tsx rename to web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx index c8d0addaa2..2684278777 100644 --- a/web/app/components/datasets/metadata/base/date-picker.spec.tsx +++ b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import WrappedDatePicker from './date-picker' +import WrappedDatePicker from '../date-picker' type TriggerArgs = { handleClickTrigger: () => void diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/add-row.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/add-row.spec.tsx similarity index 97% rename from web/app/components/datasets/metadata/edit-metadata-batch/add-row.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/add-row.spec.tsx index 0c47873b31..342bddc33f 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/add-row.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/add-row.spec.tsx @@ -1,8 +1,8 @@ -import type { MetadataItemWithEdit } from '../types' +import type { MetadataItemWithEdit } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import AddRow from './add-row' +import { DataType } from '../../types' +import AddRow from '../add-row' type InputCombinedProps = { type: DataType @@ -15,7 +15,7 @@ type LabelProps = { } // Mock InputCombined component -vi.mock('./input-combined', () => ({ +vi.mock('../input-combined', () => ({ default: ({ type, value, onChange }: InputCombinedProps) => ( <input data-testid="input-combined" @@ -27,7 +27,7 @@ vi.mock('./input-combined', () => ({ })) // Mock Label component -vi.mock('./label', () => ({ +vi.mock('../label', () => ({ default: ({ text }: LabelProps) => <div data-testid="label">{text}</div>, })) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edit-row.spec.tsx similarity index 97% rename from web/app/components/datasets/metadata/edit-metadata-batch/edit-row.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edit-row.spec.tsx index a2d743e8be..19c02198b2 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edit-row.spec.tsx @@ -1,8 +1,8 @@ -import type { MetadataItemWithEdit } from '../types' +import type { MetadataItemWithEdit } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType, UpdateType } from '../types' -import EditMetadatabatchItem from './edit-row' +import { DataType, UpdateType } from '../../types' +import EditMetadatabatchItem from '../edit-row' type InputCombinedProps = { type: DataType @@ -26,7 +26,7 @@ type EditedBeaconProps = { } // Mock InputCombined component -vi.mock('./input-combined', () => ({ +vi.mock('../input-combined', () => ({ default: ({ type, value, onChange, readOnly }: InputCombinedProps) => ( <input data-testid="input-combined" @@ -39,7 +39,7 @@ vi.mock('./input-combined', () => ({ })) // Mock InputHasSetMultipleValue component -vi.mock('./input-has-set-multiple-value', () => ({ +vi.mock('../input-has-set-multiple-value', () => ({ default: ({ onClear, readOnly }: MultipleValueInputProps) => ( <div data-testid="multiple-value-input" data-readonly={readOnly}> <button data-testid="clear-multiple" onClick={onClear}>Clear Multiple</button> @@ -48,14 +48,14 @@ vi.mock('./input-has-set-multiple-value', () => ({ })) // Mock Label component -vi.mock('./label', () => ({ +vi.mock('../label', () => ({ default: ({ text, isDeleted }: LabelProps) => ( <div data-testid="label" data-deleted={isDeleted}>{text}</div> ), })) // Mock EditedBeacon component -vi.mock('./edited-beacon', () => ({ +vi.mock('../edited-beacon', () => ({ default: ({ onReset }: EditedBeaconProps) => ( <button data-testid="edited-beacon" onClick={onReset}>Reset</button> ), diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx index 0ab4287b4c..39c8c9effc 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import EditedBeacon from './edited-beacon' +import EditedBeacon from '../edited-beacon' describe('EditedBeacon', () => { describe('Rendering', () => { @@ -115,7 +115,6 @@ describe('EditedBeacon', () => { const handleReset = vi.fn() const { container } = render(<EditedBeacon onReset={handleReset} />) - // Click on the wrapper when not hovering const wrapper = container.firstChild as HTMLElement fireEvent.click(wrapper) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/edit-metadata-batch/input-combined.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx index 2a4d092822..debfa63dc7 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import InputCombined from './input-combined' +import { DataType } from '../../types' +import InputCombined from '../input-combined' type DatePickerProps = { value: number | null @@ -10,7 +10,7 @@ type DatePickerProps = { } // Mock the base date-picker component -vi.mock('../base/date-picker', () => ({ +vi.mock('../../base/date-picker', () => ({ default: ({ value, onChange, className }: DatePickerProps) => ( <div data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}> {value || 'Pick date'} diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-has-set-multiple-value.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-has-set-multiple-value.spec.tsx index 40dd7a83b9..ef76fd361a 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-has-set-multiple-value.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import InputHasSetMultipleValue from './input-has-set-multiple-value' +import InputHasSetMultipleValue from '../input-has-set-multiple-value' describe('InputHasSetMultipleValue', () => { describe('Rendering', () => { @@ -89,7 +89,6 @@ describe('InputHasSetMultipleValue', () => { const handleClear = vi.fn() const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />) - // Click on the wrapper fireEvent.click(container.firstChild as HTMLElement) expect(handleClear).not.toHaveBeenCalled() diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/label.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/label.spec.tsx similarity index 99% rename from web/app/components/datasets/metadata/edit-metadata-batch/label.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/label.spec.tsx index 1ec62ebb94..bce0de4118 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/label.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/label.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Label from './label' +import Label from '../label' describe('Label', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/modal.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/edit-metadata-batch/modal.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx index 55cce87a40..025f3f47ae 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/modal.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx @@ -1,8 +1,8 @@ -import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types' +import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType, UpdateType } from '../types' -import EditMetadataBatchModal from './modal' +import { DataType, UpdateType } from '../../types' +import EditMetadataBatchModal from '../modal' // Mock service/API calls const mockDoAddMetaData = vi.fn().mockResolvedValue({}) @@ -22,7 +22,7 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ // Mock check name hook to control validation let mockCheckNameResult = { errorMsg: '' } -vi.mock('../hooks/use-check-metadata-name', () => ({ +vi.mock('../../hooks/use-check-metadata-name', () => ({ default: () => ({ checkName: () => mockCheckNameResult, }), @@ -58,7 +58,7 @@ type SelectModalProps = { } // Mock child components with callback exposure -vi.mock('./edit-row', () => ({ +vi.mock('../edit-row', () => ({ default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => ( <div data-testid="edit-row" data-id={payload.id}> <span data-testid="edit-row-name">{payload.name}</span> @@ -69,7 +69,7 @@ vi.mock('./edit-row', () => ({ ), })) -vi.mock('./add-row', () => ({ +vi.mock('../add-row', () => ({ default: ({ payload, onChange, onRemove }: AddRowProps) => ( <div data-testid="add-row" data-id={payload.id}> <span data-testid="add-row-name">{payload.name}</span> @@ -79,7 +79,7 @@ vi.mock('./add-row', () => ({ ), })) -vi.mock('../metadata-dataset/select-metadata-modal', () => ({ +vi.mock('../../metadata-dataset/select-metadata-modal', () => ({ default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => ( <div data-testid="select-modal"> {trigger} @@ -505,7 +505,6 @@ describe('EditMetadataBatchModal', () => { // Remove an item fireEvent.click(screen.getByTestId('remove-1')) - // Save const saveButtons = screen.getAllByText(/save/i) const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary')) if (saveBtn) diff --git a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts similarity index 99% rename from web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.spec.ts rename to web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts index c508a45dc7..bdcd2004d7 100644 --- a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts @@ -1,7 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType, UpdateType } from '../types' -import useBatchEditDocumentMetadata from './use-batch-edit-document-metadata' +import { DataType, UpdateType } from '../../types' +import useBatchEditDocumentMetadata from '../use-batch-edit-document-metadata' type DocMetadataItem = { id: string @@ -33,7 +33,6 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ }), })) -// Mock Toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), diff --git a/web/app/components/datasets/metadata/hooks/use-check-metadata-name.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-check-metadata-name.spec.ts similarity index 99% rename from web/app/components/datasets/metadata/hooks/use-check-metadata-name.spec.ts rename to web/app/components/datasets/metadata/hooks/__tests__/use-check-metadata-name.spec.ts index 14081908c0..7c06be39a9 100644 --- a/web/app/components/datasets/metadata/hooks/use-check-metadata-name.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-check-metadata-name.spec.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import useCheckMetadataName from './use-check-metadata-name' +import useCheckMetadataName from '../use-check-metadata-name' describe('useCheckMetadataName', () => { describe('Hook Initialization', () => { diff --git a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts similarity index 97% rename from web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.spec.ts rename to web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts index 132660302d..5712e82b71 100644 --- a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts @@ -1,9 +1,8 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import useEditDatasetMetadata from './use-edit-dataset-metadata' +import { DataType } from '../../types' +import useEditDatasetMetadata from '../use-edit-dataset-metadata' -// Mock service hooks const mockDoAddMetaData = vi.fn().mockResolvedValue({}) const mockDoRenameMetaData = vi.fn().mockResolvedValue({}) const mockDoDeleteMetaData = vi.fn().mockResolvedValue({}) @@ -41,7 +40,6 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ }), })) -// Mock Toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), @@ -49,7 +47,7 @@ vi.mock('@/app/components/base/toast', () => ({ })) // Mock useCheckMetadataName -vi.mock('./use-check-metadata-name', () => ({ +vi.mock('../use-check-metadata-name', () => ({ default: () => ({ checkName: (name: string) => ({ errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name', diff --git a/web/app/components/datasets/metadata/hooks/use-metadata-document.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts similarity index 98% rename from web/app/components/datasets/metadata/hooks/use-metadata-document.spec.ts rename to web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts index bbe84aaf1d..351f7fac08 100644 --- a/web/app/components/datasets/metadata/hooks/use-metadata-document.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts @@ -1,7 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import useMetadataDocument from './use-metadata-document' +import { DataType } from '../../types' +import useMetadataDocument from '../use-metadata-document' type DocDetail = { id: string @@ -13,7 +13,6 @@ type DocDetail = { segment_count?: number } -// Mock service hooks const mockMutateAsync = vi.fn().mockResolvedValue({}) const mockDoAddMetaData = vi.fn().mockResolvedValue({}) @@ -82,7 +81,6 @@ vi.mock('@/hooks/use-metadata', () => ({ }), })) -// Mock Toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), @@ -90,7 +88,7 @@ vi.mock('@/app/components/base/toast', () => ({ })) // Mock useCheckMetadataName -vi.mock('./use-check-metadata-name', () => ({ +vi.mock('../use-check-metadata-name', () => ({ default: () => ({ checkName: (name: string) => ({ errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name', diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-content.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx similarity index 97% rename from web/app/components/datasets/metadata/metadata-dataset/create-content.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx index fd064bc928..8070061776 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-content.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import CreateContent from './create-content' +import { DataType } from '../../types' +import CreateContent from '../create-content' type ModalLikeWrapProps = { children: React.ReactNode @@ -23,7 +23,7 @@ type FieldProps = { } // Mock ModalLikeWrap -vi.mock('../../../base/modal-like-wrap', () => ({ +vi.mock('../../../../base/modal-like-wrap', () => ({ default: ({ children, title, onClose, onConfirm, beforeHeader }: ModalLikeWrapProps) => ( <div data-testid="modal-wrap"> <div data-testid="modal-title">{title}</div> @@ -36,7 +36,7 @@ vi.mock('../../../base/modal-like-wrap', () => ({ })) // Mock OptionCard -vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({ +vi.mock('../../../../workflow/nodes/_base/components/option-card', () => ({ default: ({ title, selected, onSelect }: OptionCardProps) => ( <button data-testid={`option-${title.toLowerCase()}`} @@ -49,7 +49,7 @@ vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({ })) // Mock Field -vi.mock('./field', () => ({ +vi.mock('../field', () => ({ default: ({ label, children }: FieldProps) => ( <div data-testid="field"> <label data-testid="field-label">{label}</label> diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx similarity index 97% rename from web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx index 5e86521a87..3a8ed6b909 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import CreateMetadataModal from './create-metadata-modal' +import { DataType } from '../../types' +import CreateMetadataModal from '../create-metadata-modal' type PortalProps = { children: React.ReactNode @@ -26,7 +26,7 @@ type CreateContentProps = { } // Mock PortalToFollowElem components -vi.mock('../../../base/portal-to-follow-elem', () => ({ +vi.mock('../../../../base/portal-to-follow-elem', () => ({ PortalToFollowElem: ({ children, open }: PortalProps) => ( <div data-testid="portal-wrapper" data-open={open}>{children}</div> ), @@ -39,7 +39,7 @@ vi.mock('../../../base/portal-to-follow-elem', () => ({ })) // Mock CreateContent component -vi.mock('./create-content', () => ({ +vi.mock('../create-content', () => ({ default: ({ onSave, onClose, onBack, hasBack }: CreateContentProps) => ( <div data-testid="create-content"> <span data-testid="has-back">{String(hasBack)}</span> diff --git a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx index fc1f0d0990..89ddb76694 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx @@ -1,8 +1,8 @@ -import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../types' +import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import DatasetMetadataDrawer from './dataset-metadata-drawer' +import { DataType } from '../../types' +import DatasetMetadataDrawer from '../dataset-metadata-drawer' // Mock service/API calls vi.mock('@/service/knowledge/use-metadata', () => ({ @@ -16,13 +16,12 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ })) // Mock check name hook -vi.mock('../hooks/use-check-metadata-name', () => ({ +vi.mock('../../hooks/use-check-metadata-name', () => ({ default: () => ({ checkName: () => ({ errorMsg: '' }), }), })) -// Mock Toast const mockToastNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ default: { @@ -213,7 +212,6 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByTestId('create-modal')).toBeInTheDocument() }) - // Save fireEvent.click(screen.getByTestId('create-save')) await waitFor(() => { @@ -400,7 +398,6 @@ describe('DatasetMetadataDrawer', () => { const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive') expect(deleteContainer).toBeTruthy() - // Click delete icon if (deleteContainer) { const deleteIcon = deleteContainer.querySelector('svg') if (deleteIcon) @@ -444,7 +441,6 @@ describe('DatasetMetadataDrawer', () => { expect(hasConfirmBtn).toBe(true) }) - // Click confirm const confirmBtns = screen.getAllByRole('button') const confirmBtn = confirmBtns.find(btn => btn.textContent?.toLowerCase().includes('confirm'), @@ -491,7 +487,6 @@ describe('DatasetMetadataDrawer', () => { expect(hasConfirmBtn).toBe(true) }) - // Click cancel const cancelBtns = screen.getAllByRole('button') const cancelBtn = cancelBtns.find(btn => btn.textContent?.toLowerCase().includes('cancel'), diff --git a/web/app/components/datasets/metadata/metadata-dataset/field.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/field.spec.tsx similarity index 99% rename from web/app/components/datasets/metadata/metadata-dataset/field.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/field.spec.tsx index e3a34f9d98..030ab4bdb0 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/field.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/field.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Field from './field' +import Field from '../field' describe('Field', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx similarity index 97% rename from web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx index 6e565c0b07..800ffc3586 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import SelectMetadataModal from './select-metadata-modal' +import { DataType } from '../../types' +import SelectMetadataModal from '../select-metadata-modal' type MetadataItem = { id: string @@ -50,7 +50,7 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ })) // Mock PortalToFollowElem components -vi.mock('../../../base/portal-to-follow-elem', () => ({ +vi.mock('../../../../base/portal-to-follow-elem', () => ({ PortalToFollowElem: ({ children, open }: PortalProps) => ( <div data-testid="portal-wrapper" data-open={open}>{children}</div> ), @@ -63,7 +63,7 @@ vi.mock('../../../base/portal-to-follow-elem', () => ({ })) // Mock SelectMetadata component -vi.mock('./select-metadata', () => ({ +vi.mock('../select-metadata', () => ({ default: ({ onSelect, onNew, onManage, list }: SelectMetadataProps) => ( <div data-testid="select-metadata"> <span data-testid="list-count">{list?.length || 0}</span> @@ -75,7 +75,7 @@ vi.mock('./select-metadata', () => ({ })) // Mock CreateContent component -vi.mock('./create-content', () => ({ +vi.mock('../create-content', () => ({ default: ({ onSave, onBack, onClose, hasBack }: CreateContentProps) => ( <div data-testid="create-content"> <button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'new_field' })}>Save</button> diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/metadata-dataset/select-metadata.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx index 2602fd145f..c1406d1233 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/select-metadata.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx @@ -1,15 +1,15 @@ -import type { MetadataItem } from '../types' +import type { MetadataItem } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import SelectMetadata from './select-metadata' +import { DataType } from '../../types' +import SelectMetadata from '../select-metadata' type IconProps = { className?: string } // Mock getIcon utility -vi.mock('../utils/get-icon', () => ({ +vi.mock('../../utils/get-icon', () => ({ getIcon: () => (props: IconProps) => <span data-testid="icon" className={props.className}>Icon</span>, })) diff --git a/web/app/components/datasets/metadata/metadata-document/field.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/field.spec.tsx similarity index 99% rename from web/app/components/datasets/metadata/metadata-document/field.spec.tsx rename to web/app/components/datasets/metadata/metadata-document/__tests__/field.spec.tsx index 50aad1a6cc..714dd0c6bb 100644 --- a/web/app/components/datasets/metadata/metadata-document/field.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/field.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Field from './field' +import Field from '../field' describe('Field', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/metadata/metadata-document/index.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/metadata-document/index.spec.tsx rename to web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx index f80b6ca010..e56fe46422 100644 --- a/web/app/components/datasets/metadata/metadata-document/index.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { MetadataItemWithValue } from '../types' +import type { MetadataItemWithValue } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import MetadataDocument from './index' +import { DataType } from '../../types' +import MetadataDocument from '../index' type MockHookReturn = { embeddingAvailable: boolean @@ -25,7 +25,7 @@ type MockHookReturn = { // Mock useMetadataDocument hook - need to control state const mockUseMetadataDocument = vi.fn<() => MockHookReturn>() -vi.mock('../hooks/use-metadata-document', () => ({ +vi.mock('../../hooks/use-metadata-document', () => ({ default: () => mockUseMetadataDocument(), })) @@ -39,13 +39,12 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ })) // Mock check name hook -vi.mock('../hooks/use-check-metadata-name', () => ({ +vi.mock('../../hooks/use-check-metadata-name', () => ({ default: () => ({ checkName: () => ({ errorMsg: '' }), }), })) -// Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -483,7 +482,6 @@ describe('MetadataDocument', () => { const deleteContainers = container.querySelectorAll('.hover\\:bg-state-destructive-hover') expect(deleteContainers.length).toBeGreaterThan(0) - // Click the delete icon (SVG inside the container) if (deleteContainers.length > 0) { const deleteIcon = deleteContainers[0].querySelector('svg') if (deleteIcon) diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx similarity index 96% rename from web/app/components/datasets/metadata/metadata-document/info-group.spec.tsx rename to web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx index d8585d0170..f30e188cd7 100644 --- a/web/app/components/datasets/metadata/metadata-document/info-group.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx @@ -1,8 +1,8 @@ -import type { MetadataItemWithValue } from '../types' +import type { MetadataItemWithValue } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import InfoGroup from './info-group' +import { DataType } from '../../types' +import InfoGroup from '../info-group' type SelectModalProps = { trigger: React.ReactNode @@ -22,7 +22,6 @@ type InputCombinedProps = { type: DataType } -// Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -41,12 +40,12 @@ vi.mock('@/hooks/use-timestamp', () => ({ })) // Mock AddMetadataButton -vi.mock('../add-metadata-button', () => ({ +vi.mock('../../add-metadata-button', () => ({ default: () => <button data-testid="add-metadata-btn">Add Metadata</button>, })) // Mock InputCombined -vi.mock('../edit-metadata-batch/input-combined', () => ({ +vi.mock('../../edit-metadata-batch/input-combined', () => ({ default: ({ value, onChange, type }: InputCombinedProps) => ( <input data-testid="input-combined" @@ -58,7 +57,7 @@ vi.mock('../edit-metadata-batch/input-combined', () => ({ })) // Mock SelectMetadataModal -vi.mock('../metadata-dataset/select-metadata-modal', () => ({ +vi.mock('../../metadata-dataset/select-metadata-modal', () => ({ default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => ( <div data-testid="select-metadata-modal"> {trigger} @@ -70,7 +69,7 @@ vi.mock('../metadata-dataset/select-metadata-modal', () => ({ })) // Mock Field -vi.mock('./field', () => ({ +vi.mock('../field', () => ({ default: ({ label, children }: FieldProps) => ( <div data-testid="field"> <span data-testid="field-label">{label}</span> diff --git a/web/app/components/datasets/metadata/metadata-document/no-data.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/no-data.spec.tsx similarity index 99% rename from web/app/components/datasets/metadata/metadata-document/no-data.spec.tsx rename to web/app/components/datasets/metadata/metadata-document/__tests__/no-data.spec.tsx index 84079cda1d..975c923db7 100644 --- a/web/app/components/datasets/metadata/metadata-document/no-data.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/no-data.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import NoData from './no-data' +import NoData from '../no-data' describe('NoData', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/metadata/utils/get-icon.spec.ts b/web/app/components/datasets/metadata/utils/__tests__/get-icon.spec.ts similarity index 94% rename from web/app/components/datasets/metadata/utils/get-icon.spec.ts rename to web/app/components/datasets/metadata/utils/__tests__/get-icon.spec.ts index f5a34bd264..07eef6c320 100644 --- a/web/app/components/datasets/metadata/utils/get-icon.spec.ts +++ b/web/app/components/datasets/metadata/utils/__tests__/get-icon.spec.ts @@ -1,7 +1,7 @@ import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react' import { describe, expect, it } from 'vitest' -import { DataType } from '../types' -import { getIcon } from './get-icon' +import { DataType } from '../../types' +import { getIcon } from '../get-icon' describe('getIcon', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/preview/__tests__/container.spec.tsx b/web/app/components/datasets/preview/__tests__/container.spec.tsx new file mode 100644 index 0000000000..86f6e3f85b --- /dev/null +++ b/web/app/components/datasets/preview/__tests__/container.spec.tsx @@ -0,0 +1,173 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import PreviewContainer from '../container' + +// Tests for PreviewContainer - a layout wrapper with header and scrollable main area +describe('PreviewContainer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render header content in a header element', () => { + render(<PreviewContainer header={<span>Header Title</span>}>Body</PreviewContainer>) + + expect(screen.getByText('Header Title')).toBeInTheDocument() + const headerEl = screen.getByText('Header Title').closest('header') + expect(headerEl).toBeInTheDocument() + }) + + it('should render children in a main element', () => { + render(<PreviewContainer header="Header">Main content</PreviewContainer>) + + const mainEl = screen.getByRole('main') + expect(mainEl).toHaveTextContent('Main content') + }) + + it('should render both header and children simultaneously', () => { + render( + <PreviewContainer header={<h2>My Header</h2>}> + <p>Body paragraph</p> + </PreviewContainer>, + ) + + expect(screen.getByText('My Header')).toBeInTheDocument() + expect(screen.getByText('Body paragraph')).toBeInTheDocument() + }) + + it('should render without children', () => { + render(<PreviewContainer header="Header" />) + + expect(screen.getByRole('main')).toBeInTheDocument() + expect(screen.getByRole('main').childElementCount).toBe(0) + }) + }) + + describe('Props', () => { + it('should apply className to the outer wrapper div', () => { + const { container } = render( + <PreviewContainer header="Header" className="outer-class">Content</PreviewContainer>, + ) + + expect(container.firstElementChild).toHaveClass('outer-class') + }) + + it('should apply mainClassName to the main element', () => { + render( + <PreviewContainer header="Header" mainClassName="custom-main">Content</PreviewContainer>, + ) + + const mainEl = screen.getByRole('main') + expect(mainEl).toHaveClass('custom-main') + // Default classes should still be present + expect(mainEl).toHaveClass('w-full', 'grow', 'overflow-y-auto', 'px-6', 'py-5') + }) + + it('should forward ref to the inner container div', () => { + const ref = vi.fn() + render( + <PreviewContainer header="Header" ref={ref}>Content</PreviewContainer>, + ) + + expect(ref).toHaveBeenCalled() + const refArg = ref.mock.calls[0][0] + expect(refArg).toBeInstanceOf(HTMLDivElement) + }) + + it('should pass rest props to the inner container div', () => { + render( + <PreviewContainer header="Header" data-testid="inner-container" id="container-1"> + Content + </PreviewContainer>, + ) + + const inner = screen.getByTestId('inner-container') + expect(inner).toHaveAttribute('id', 'container-1') + }) + + it('should render ReactNode as header', () => { + render( + <PreviewContainer header={<div data-testid="complex-header"><span>Complex</span></div>}> + Content + </PreviewContainer>, + ) + + expect(screen.getByTestId('complex-header')).toBeInTheDocument() + expect(screen.getByText('Complex')).toBeInTheDocument() + }) + }) + + // Layout structure tests + describe('Layout Structure', () => { + it('should have header with border-b styling', () => { + render(<PreviewContainer header="Header">Content</PreviewContainer>) + + const headerEl = screen.getByText('Header').closest('header') + expect(headerEl).toHaveClass('border-b', 'border-divider-subtle') + }) + + it('should have inner div with flex column layout', () => { + render( + <PreviewContainer header="Header" data-testid="inner">Content</PreviewContainer>, + ) + + const inner = screen.getByTestId('inner') + expect(inner).toHaveClass('flex', 'h-full', 'w-full', 'flex-col') + }) + + it('should have main with overflow-y-auto for scrolling', () => { + render(<PreviewContainer header="Header">Content</PreviewContainer>) + + expect(screen.getByRole('main')).toHaveClass('overflow-y-auto') + }) + }) + + // DisplayName test + describe('DisplayName', () => { + it('should have correct displayName', () => { + expect(PreviewContainer.displayName).toBe('PreviewContainer') + }) + }) + + describe('Edge Cases', () => { + it('should render with empty string header', () => { + render(<PreviewContainer header="">Content</PreviewContainer>) + + const headerEl = screen.getByRole('banner') + expect(headerEl).toBeInTheDocument() + }) + + it('should render with null children', () => { + render(<PreviewContainer header="Header">{null}</PreviewContainer>) + + expect(screen.getByRole('main')).toBeInTheDocument() + }) + + it('should render with multiple children', () => { + render( + <PreviewContainer header="Header"> + <div>Child 1</div> + <div>Child 2</div> + <div>Child 3</div> + </PreviewContainer>, + ) + + expect(screen.getByText('Child 1')).toBeInTheDocument() + expect(screen.getByText('Child 2')).toBeInTheDocument() + expect(screen.getByText('Child 3')).toBeInTheDocument() + }) + + it('should not crash on re-render with different props', () => { + const { rerender } = render( + <PreviewContainer header="First" className="a">Content A</PreviewContainer>, + ) + + rerender( + <PreviewContainer header="Second" className="b" mainClassName="new-main">Content B</PreviewContainer>, + ) + + expect(screen.getByText('Second')).toBeInTheDocument() + expect(screen.getByText('Content B')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/preview/__tests__/header.spec.tsx b/web/app/components/datasets/preview/__tests__/header.spec.tsx new file mode 100644 index 0000000000..8f7e44e18c --- /dev/null +++ b/web/app/components/datasets/preview/__tests__/header.spec.tsx @@ -0,0 +1,141 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PreviewHeader } from '../header' + +// Tests for PreviewHeader - displays a title and optional children +describe('PreviewHeader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the title text', () => { + render(<PreviewHeader title="Preview Title" />) + + expect(screen.getByText('Preview Title')).toBeInTheDocument() + }) + + it('should render children below the title', () => { + render( + <PreviewHeader title="Title"> + <span>Child content</span> + </PreviewHeader>, + ) + + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Child content')).toBeInTheDocument() + }) + + it('should render without children', () => { + const { container } = render(<PreviewHeader title="Solo Title" />) + + expect(container.firstElementChild).toBeInTheDocument() + expect(screen.getByText('Solo Title')).toBeInTheDocument() + }) + + it('should render title in an inner div with uppercase styling', () => { + render(<PreviewHeader title="Styled Title" />) + + const titleEl = screen.getByText('Styled Title') + expect(titleEl).toHaveClass('uppercase', 'mb-1', 'px-1', 'text-text-accent') + }) + }) + + describe('Props', () => { + it('should apply custom className to outer div', () => { + render(<PreviewHeader title="Title" className="custom-header" data-testid="header" />) + + expect(screen.getByTestId('header')).toHaveClass('custom-header') + }) + + it('should pass rest props to the outer div', () => { + render(<PreviewHeader title="Title" data-testid="header" id="header-1" aria-label="preview header" />) + + const el = screen.getByTestId('header') + expect(el).toHaveAttribute('id', 'header-1') + expect(el).toHaveAttribute('aria-label', 'preview header') + }) + + it('should render with empty string title', () => { + render(<PreviewHeader title="" data-testid="header" />) + + const header = screen.getByTestId('header') + // Title div exists but is empty + const titleDiv = header.querySelector('.uppercase') + expect(titleDiv).toBeInTheDocument() + expect(titleDiv?.textContent).toBe('') + }) + }) + + describe('Structure', () => { + it('should render as a div element', () => { + render(<PreviewHeader title="Title" data-testid="header" />) + + expect(screen.getByTestId('header').tagName).toBe('DIV') + }) + + it('should have title div as the first child', () => { + render(<PreviewHeader title="Title" data-testid="header" />) + + const header = screen.getByTestId('header') + const firstChild = header.firstElementChild + expect(firstChild).toHaveTextContent('Title') + }) + + it('should place children after the title div', () => { + render( + <PreviewHeader title="Title" data-testid="header"> + <button>Action</button> + </PreviewHeader>, + ) + + const header = screen.getByTestId('header') + const children = Array.from(header.children) + expect(children).toHaveLength(2) + expect(children[0]).toHaveTextContent('Title') + expect(children[1]).toHaveTextContent('Action') + }) + }) + + describe('Edge Cases', () => { + it('should handle special characters in title', () => { + render(<PreviewHeader title="Test & <Special> 'Characters'" />) + + expect(screen.getByText('Test & <Special> \'Characters\'')).toBeInTheDocument() + }) + + it('should handle long titles', () => { + const longTitle = 'A'.repeat(500) + render(<PreviewHeader title={longTitle} />) + + expect(screen.getByText(longTitle)).toBeInTheDocument() + }) + + it('should render multiple children', () => { + render( + <PreviewHeader title="Title"> + <span>First</span> + <span>Second</span> + </PreviewHeader>, + ) + + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.getByText('Second')).toBeInTheDocument() + }) + + it('should render with null children', () => { + render(<PreviewHeader title="Title">{null}</PreviewHeader>) + + expect(screen.getByText('Title')).toBeInTheDocument() + }) + + it('should not crash on re-render with different title', () => { + const { rerender } = render(<PreviewHeader title="First Title" />) + + rerender(<PreviewHeader title="Second Title" />) + + expect(screen.queryByText('First Title')).not.toBeInTheDocument() + expect(screen.getByText('Second Title')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/preview/index.spec.tsx b/web/app/components/datasets/preview/__tests__/index.spec.tsx similarity index 94% rename from web/app/components/datasets/preview/index.spec.tsx rename to web/app/components/datasets/preview/__tests__/index.spec.tsx index 56638fb612..298d589001 100644 --- a/web/app/components/datasets/preview/index.spec.tsx +++ b/web/app/components/datasets/preview/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import DatasetPreview from './index' +import DatasetPreview from '../index' afterEach(() => { cleanup() diff --git a/web/app/components/datasets/rename-modal/index.spec.tsx b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/rename-modal/index.spec.tsx rename to web/app/components/datasets/rename-modal/__tests__/index.spec.tsx index 13ab4d25ea..a29fc0a74c 100644 --- a/web/app/components/datasets/rename-modal/index.spec.tsx +++ b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx @@ -2,31 +2,29 @@ import type { DataSet } from '@/models/datasets' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import RenameDatasetModal from './index' +import RenameDatasetModal from '../index' -// Mock service const mockUpdateDatasetSetting = vi.fn() vi.mock('@/service/datasets', () => ({ updateDatasetSetting: (params: unknown) => mockUpdateDatasetSetting(params), })) -// Mock Toast const mockToastNotify = vi.fn() -vi.mock('../../base/toast', () => ({ +vi.mock('../../../base/toast', () => ({ default: { notify: (params: unknown) => mockToastNotify(params), }, })) // Mock AppIcon - simplified mock to enable testing onClick callback -vi.mock('../../base/app-icon', () => ({ +vi.mock('../../../base/app-icon', () => ({ default: ({ onClick }: { onClick?: () => void }) => ( <button data-testid="app-icon" onClick={onClick}>Icon</button> ), })) // Mock AppIconPicker - simplified mock to test onSelect and onClose callbacks -vi.mock('../../base/app-icon-picker', () => ({ +vi.mock('../../../base/app-icon-picker', () => ({ default: ({ onSelect, onClose }: { onSelect?: (icon: { type: string, icon?: string, background?: string, fileId?: string, url?: string }) => void onClose?: () => void @@ -43,7 +41,6 @@ vi.mock('../../base/app-icon-picker', () => ({ ), })) -// Note: react-i18next is globally mocked in vitest.setup.ts // The mock returns 'ns.key' format, e.g., 'common.operation.cancel' describe('RenameDatasetModal', () => { @@ -748,7 +745,6 @@ describe('RenameDatasetModal', () => { // Initially picker should not be visible expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() - // Click app icon to open picker const appIcon = screen.getByTestId('app-icon') await act(async () => { fireEvent.click(appIcon) diff --git a/web/app/components/datasets/settings/option-card.spec.tsx b/web/app/components/datasets/settings/__tests__/option-card.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/option-card.spec.tsx rename to web/app/components/datasets/settings/__tests__/option-card.spec.tsx index 6fdcd8faa7..ba670dc144 100644 --- a/web/app/components/datasets/settings/option-card.spec.tsx +++ b/web/app/components/datasets/settings/__tests__/option-card.spec.tsx @@ -1,8 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' -import { EffectColor } from './chunk-structure/types' -import OptionCard from './option-card' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import { EffectColor } from '../chunk-structure/types' +import OptionCard from '../option-card' describe('OptionCard', () => { const defaultProps = { diff --git a/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx b/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx new file mode 100644 index 0000000000..39b4ffc784 --- /dev/null +++ b/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx @@ -0,0 +1,226 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SummaryIndexSetting from '../summary-index-setting' + +// Mock useModelList to return a list of text generation models +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: () => ({ + data: [ + { + provider: 'openai', + label: { en_US: 'OpenAI' }, + models: [ + { model: 'gpt-4', label: { en_US: 'GPT-4' }, model_type: 'llm', status: 'active' }, + ], + }, + ], + }), +})) + +// Mock ModelSelector (external component from header module) +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: ({ onSelect, readonly, defaultModel }: { onSelect?: (val: Record<string, string>) => void, readonly?: boolean, defaultModel?: { model?: string } }) => ( + <div data-testid="model-selector" data-readonly={readonly}> + <span data-testid="current-model">{defaultModel?.model || 'none'}</span> + <button + data-testid="select-model-btn" + onClick={() => onSelect?.({ provider: 'openai', model: 'gpt-4' })} + > + Select + </button> + </div> + ), +})) + +const ns = 'datasetSettings' + +describe('SummaryIndexSetting', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('knowledge-base entry', () => { + it('should render auto gen label and switch', () => { + render(<SummaryIndexSetting entry="knowledge-base" />) + expect(screen.getByText(`${ns}.form.summaryAutoGen`)).toBeInTheDocument() + }) + + it('should render switch with defaultValue false when no setting', () => { + render(<SummaryIndexSetting entry="knowledge-base" />) + // Switch is rendered; no model selector without enable + expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() + }) + + it('should show model selector and textarea when enabled', () => { + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ + enable: true, + model_provider_name: 'openai', + model_name: 'gpt-4', + }} + />, + ) + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + expect(screen.getByText(`${ns}.form.summaryModel`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.form.summaryInstructions`)).toBeInTheDocument() + }) + + it('should call onSummaryIndexSettingChange with enable toggle', () => { + const onChange = vi.fn() + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: false }} + onSummaryIndexSettingChange={onChange} + />, + ) + // Find and click the switch + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + expect(onChange).toHaveBeenCalledWith({ enable: true }) + }) + + it('should call onSummaryIndexSettingChange when model selected', () => { + const onChange = vi.fn() + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true, model_provider_name: 'openai', model_name: 'gpt-4' }} + onSummaryIndexSettingChange={onChange} + />, + ) + fireEvent.click(screen.getByTestId('select-model-btn')) + expect(onChange).toHaveBeenCalledWith({ model_provider_name: 'openai', model_name: 'gpt-4' }) + }) + + it('should call onSummaryIndexSettingChange when prompt changed', () => { + const onChange = vi.fn() + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true, summary_prompt: '' }} + onSummaryIndexSettingChange={onChange} + />, + ) + const textarea = screen.getByPlaceholderText(`${ns}.form.summaryInstructionsPlaceholder`) + fireEvent.change(textarea, { target: { value: 'Summarize this' } }) + expect(onChange).toHaveBeenCalledWith({ summary_prompt: 'Summarize this' }) + }) + }) + + describe('dataset-settings entry', () => { + it('should render auto gen label with switch', () => { + render(<SummaryIndexSetting entry="dataset-settings" />) + expect(screen.getByText(`${ns}.form.summaryAutoGen`)).toBeInTheDocument() + }) + + it('should show disabled text when not enabled', () => { + render( + <SummaryIndexSetting + entry="dataset-settings" + summaryIndexSetting={{ enable: false }} + />, + ) + expect(screen.getByText(`${ns}.form.summaryAutoGenEnableTip`)).toBeInTheDocument() + }) + + it('should show enabled tip when enabled', () => { + render( + <SummaryIndexSetting + entry="dataset-settings" + summaryIndexSetting={{ enable: true }} + />, + ) + expect(screen.getByText(`${ns}.form.summaryAutoGenTip`)).toBeInTheDocument() + }) + + it('should show model selector and textarea when enabled', () => { + render( + <SummaryIndexSetting + entry="dataset-settings" + summaryIndexSetting={{ enable: true, model_provider_name: 'openai', model_name: 'gpt-4' }} + />, + ) + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + expect(screen.getByText(`${ns}.form.summaryModel`)).toBeInTheDocument() + }) + }) + + describe('create-document entry', () => { + it('should render auto gen label with switch', () => { + render(<SummaryIndexSetting entry="create-document" />) + expect(screen.getByText(`${ns}.form.summaryAutoGen`)).toBeInTheDocument() + }) + + it('should show model selector and textarea when enabled', () => { + render( + <SummaryIndexSetting + entry="create-document" + summaryIndexSetting={{ enable: true, model_provider_name: 'openai', model_name: 'gpt-4' }} + />, + ) + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + expect(screen.getByText(`${ns}.form.summaryModel`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.form.summaryInstructions`)).toBeInTheDocument() + }) + + it('should not show model selector when disabled', () => { + render( + <SummaryIndexSetting + entry="create-document" + summaryIndexSetting={{ enable: false }} + />, + ) + expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() + }) + }) + + describe('readonly mode', () => { + it('should pass readonly to model selector in knowledge-base entry', () => { + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true, model_provider_name: 'openai', model_name: 'gpt-4' }} + readonly + />, + ) + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-readonly', 'true') + }) + + it('should disable textarea in readonly mode', () => { + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true, summary_prompt: 'test' }} + readonly + />, + ) + const textarea = screen.getByPlaceholderText(`${ns}.form.summaryInstructionsPlaceholder`) + expect(textarea).toBeDisabled() + }) + }) + + describe('model config derivation', () => { + it('should pass correct defaultModel when provider and model are set', () => { + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true, model_provider_name: 'anthropic', model_name: 'claude-3' }} + />, + ) + expect(screen.getByTestId('current-model')).toHaveTextContent('claude-3') + }) + + it('should pass undefined defaultModel when provider is missing', () => { + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true }} + />, + ) + expect(screen.getByTestId('current-model')).toHaveTextContent('none') + }) + }) +}) diff --git a/web/app/components/datasets/settings/chunk-structure/hooks.spec.tsx b/web/app/components/datasets/settings/chunk-structure/__tests__/hooks.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/chunk-structure/hooks.spec.tsx rename to web/app/components/datasets/settings/chunk-structure/__tests__/hooks.spec.tsx index 668d2f926f..8d44d19d09 100644 --- a/web/app/components/datasets/settings/chunk-structure/hooks.spec.tsx +++ b/web/app/components/datasets/settings/chunk-structure/__tests__/hooks.spec.tsx @@ -1,8 +1,6 @@ import { renderHook } from '@testing-library/react' -import { useChunkStructure } from './hooks' -import { EffectColor } from './types' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import { useChunkStructure } from '../hooks' +import { EffectColor } from '../types' describe('useChunkStructure', () => { describe('Hook Initialization', () => { diff --git a/web/app/components/datasets/settings/chunk-structure/index.spec.tsx b/web/app/components/datasets/settings/chunk-structure/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/chunk-structure/index.spec.tsx rename to web/app/components/datasets/settings/chunk-structure/__tests__/index.spec.tsx index 0206617c94..1ebc6da6cb 100644 --- a/web/app/components/datasets/settings/chunk-structure/index.spec.tsx +++ b/web/app/components/datasets/settings/chunk-structure/__tests__/index.spec.tsx @@ -1,8 +1,6 @@ import { render, screen } from '@testing-library/react' import { ChunkingMode } from '@/models/datasets' -import ChunkStructure from './index' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import ChunkStructure from '../index' describe('ChunkStructure', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/settings/form/index.spec.tsx b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/settings/form/index.spec.tsx rename to web/app/components/datasets/settings/form/__tests__/index.spec.tsx index 03e98861e2..b2a2e3c9d8 100644 --- a/web/app/components/datasets/settings/form/index.spec.tsx +++ b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx @@ -3,8 +3,8 @@ import type { RetrievalConfig } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../create/step-two' -import Form from './index' +import { IndexingType } from '../../../create/step-two' +import Form from '../index' // Mock contexts const mockMutateDatasets = vi.fn() @@ -374,7 +374,6 @@ describe('Form', () => { const nameInput = screen.getByDisplayValue('Test Dataset') fireEvent.change(nameInput, { target: { value: 'New Dataset Name' } }) - // Save const saveButton = screen.getByRole('button', { name: /form\.save/i }) fireEvent.click(saveButton) @@ -397,7 +396,6 @@ describe('Form', () => { const descriptionTextarea = screen.getByDisplayValue('Test description') fireEvent.change(descriptionTextarea, { target: { value: 'New description' } }) - // Save const saveButton = screen.getByRole('button', { name: /form\.save/i }) fireEvent.click(saveButton) diff --git a/web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx rename to web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx index 28085e52fa..618a28d498 100644 --- a/web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx @@ -4,8 +4,8 @@ import type { RetrievalConfig } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../create/step-two' -import BasicInfoSection from './basic-info-section' +import { IndexingType } from '../../../../create/step-two' +import BasicInfoSection from '../basic-info-section' // Mock app-context vi.mock('@/context/app-context', () => ({ @@ -325,12 +325,10 @@ describe('BasicInfoSection', () => { const setPermission = vi.fn() render(<BasicInfoSection {...defaultProps} setPermission={setPermission} />) - // Open dropdown const trigger = screen.getByText(/form\.permissionsOnlyMe/i) fireEvent.click(trigger) await waitFor(() => { - // Click All Team Members option const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/i) fireEvent.click(allMemberOptions[0]) }) diff --git a/web/app/components/datasets/settings/form/components/external-knowledge-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx similarity index 99% rename from web/app/components/datasets/settings/form/components/external-knowledge-section.spec.tsx rename to web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx index 96512b5aca..fd2e83892f 100644 --- a/web/app/components/datasets/settings/form/components/external-knowledge-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx @@ -3,8 +3,8 @@ import type { RetrievalConfig } from '@/types/app' import { render, screen } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../create/step-two' -import ExternalKnowledgeSection from './external-knowledge-section' +import { IndexingType } from '../../../../create/step-two' +import ExternalKnowledgeSection from '../external-knowledge-section' describe('ExternalKnowledgeSection', () => { const mockRetrievalConfig: RetrievalConfig = { diff --git a/web/app/components/datasets/settings/form/components/indexing-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx similarity index 99% rename from web/app/components/datasets/settings/form/components/indexing-section.spec.tsx rename to web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx index bf1448b933..f49bdbc576 100644 --- a/web/app/components/datasets/settings/form/components/indexing-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx @@ -5,8 +5,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../create/step-two' -import IndexingSection from './indexing-section' +import { IndexingType } from '../../../../create/step-two' +import IndexingSection from '../indexing-section' // Mock i18n doc link vi.mock('@/context/i18n', () => ({ diff --git a/web/app/components/datasets/settings/form/hooks/use-form-state.spec.ts b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts similarity index 99% rename from web/app/components/datasets/settings/form/hooks/use-form-state.spec.ts rename to web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts index f79500544b..f27b542b1e 100644 --- a/web/app/components/datasets/settings/form/hooks/use-form-state.spec.ts +++ b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts @@ -3,8 +3,8 @@ import type { RetrievalConfig } from '@/types/app' import { act, renderHook, waitFor } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../create/step-two' -import { useFormState } from './use-form-state' +import { IndexingType } from '../../../../create/step-two' +import { useFormState } from '../use-form-state' // Mock contexts const mockMutateDatasets = vi.fn() diff --git a/web/app/components/datasets/settings/index-method/index.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/datasets/settings/index-method/index.spec.tsx rename to web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx index dbb886c676..dbdb9cf6f1 100644 --- a/web/app/components/datasets/settings/index-method/index.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx @@ -1,8 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' -import { IndexingType } from '../../create/step-two' -import IndexMethod from './index' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import { IndexingType } from '../../../create/step-two' +import IndexMethod from '../index' describe('IndexMethod', () => { const defaultProps = { @@ -92,7 +90,6 @@ describe('IndexMethod', () => { const handleChange = vi.fn() render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />) - // Click on already active High Quality const highQualityTitle = screen.getByText(/stepTwo\.qualified/) const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement fireEvent.click(card!) diff --git a/web/app/components/datasets/settings/index-method/keyword-number.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/index-method/keyword-number.spec.tsx rename to web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx index f0f7f69de5..42d3b953f5 100644 --- a/web/app/components/datasets/settings/index-method/keyword-number.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx @@ -1,7 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import KeyWordNumber from './keyword-number' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import KeyWordNumber from '../keyword-number' describe('KeyWordNumber', () => { const defaultProps = { diff --git a/web/app/components/datasets/settings/permission-selector/index.spec.tsx b/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx similarity index 96% rename from web/app/components/datasets/settings/permission-selector/index.spec.tsx rename to web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx index 0e8a82c102..987d524090 100644 --- a/web/app/components/datasets/settings/permission-selector/index.spec.tsx +++ b/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import type { Member } from '@/models/common' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { DatasetPermission } from '@/models/datasets' -import PermissionSelector from './index' +import PermissionSelector from '../index' // Mock app-context vi.mock('@/context/app-context', () => ({ @@ -14,8 +14,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -// Note: react-i18next is globally mocked in vitest.setup.ts - describe('PermissionSelector', () => { const mockMemberList: Member[] = [ { id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' }, @@ -94,12 +92,10 @@ describe('PermissionSelector', () => { const handleChange = vi.fn() render(<PermissionSelector {...defaultProps} onChange={handleChange} permission={DatasetPermission.allTeamMembers} />) - // Open dropdown const trigger = screen.getByText(/form\.permissionsAllMember/) fireEvent.click(trigger) await waitFor(() => { - // Click Only Me option const onlyMeOptions = screen.getAllByText(/form\.permissionsOnlyMe/) fireEvent.click(onlyMeOptions[0]) }) @@ -111,12 +107,10 @@ describe('PermissionSelector', () => { const handleChange = vi.fn() render(<PermissionSelector {...defaultProps} onChange={handleChange} />) - // Open dropdown const trigger = screen.getByText(/form\.permissionsOnlyMe/) fireEvent.click(trigger) await waitFor(() => { - // Click All Team Members option const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/) fireEvent.click(allMemberOptions[0]) }) @@ -135,12 +129,10 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByText(/form\.permissionsOnlyMe/) fireEvent.click(trigger) await waitFor(() => { - // Click Invited Members option const invitedOptions = screen.getAllByText(/form\.permissionsInvitedMembers/) fireEvent.click(invitedOptions[0]) }) @@ -159,7 +151,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -180,12 +171,10 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) await waitFor(() => { - // Click on John Doe const johnDoe = screen.getByText('John Doe') fireEvent.click(johnDoe) }) @@ -204,12 +193,10 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) await waitFor(() => { - // Click on John Doe to deselect const johnDoe = screen.getByText('John Doe') fireEvent.click(johnDoe) }) @@ -227,7 +214,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -247,7 +233,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -264,7 +249,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -291,7 +275,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -302,7 +285,6 @@ describe('PermissionSelector', () => { fireEvent.change(searchInput, { target: { value: 'test' } }) expect(searchInput).toHaveValue('test') - // Click the clear button using data-testid const clearButton = screen.getByTestId('input-clear') fireEvent.click(clearButton) @@ -320,7 +302,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -347,7 +328,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -374,7 +354,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -399,7 +378,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) diff --git a/web/app/components/datasets/settings/permission-selector/member-item.spec.tsx b/web/app/components/datasets/settings/permission-selector/__tests__/member-item.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/permission-selector/member-item.spec.tsx rename to web/app/components/datasets/settings/permission-selector/__tests__/member-item.spec.tsx index 02d453db7c..bd3d830137 100644 --- a/web/app/components/datasets/settings/permission-selector/member-item.spec.tsx +++ b/web/app/components/datasets/settings/permission-selector/__tests__/member-item.spec.tsx @@ -1,7 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import MemberItem from './member-item' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import MemberItem from '../member-item' describe('MemberItem', () => { const defaultProps = { diff --git a/web/app/components/datasets/settings/permission-selector/permission-item.spec.tsx b/web/app/components/datasets/settings/permission-selector/__tests__/permission-item.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/permission-selector/permission-item.spec.tsx rename to web/app/components/datasets/settings/permission-selector/__tests__/permission-item.spec.tsx index 5f6b881bd4..5054bb3b9b 100644 --- a/web/app/components/datasets/settings/permission-selector/permission-item.spec.tsx +++ b/web/app/components/datasets/settings/permission-selector/__tests__/permission-item.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import PermissionItem from './permission-item' +import PermissionItem from '../permission-item' describe('PermissionItem', () => { const defaultProps = { diff --git a/web/app/components/datasets/settings/utils/index.spec.ts b/web/app/components/datasets/settings/utils/__tests__/index.spec.ts similarity index 98% rename from web/app/components/datasets/settings/utils/index.spec.ts rename to web/app/components/datasets/settings/utils/__tests__/index.spec.ts index 5a9099e51f..9a51873b1f 100644 --- a/web/app/components/datasets/settings/utils/index.spec.ts +++ b/web/app/components/datasets/settings/utils/__tests__/index.spec.ts @@ -1,7 +1,7 @@ import type { DefaultModel, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { IndexingType } from '../../create/step-two' -import { checkShowMultiModalTip } from './index' +import { IndexingType } from '../../../create/step-two' +import { checkShowMultiModalTip } from '../index' describe('checkShowMultiModalTip', () => { // Helper to create a model item with specific features diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index a939fd7d2f..e49d1d8d23 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -3091,21 +3091,11 @@ "count": 3 } }, - "app/components/datasets/common/document-picker/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/common/document-picker/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 } }, - "app/components/datasets/common/document-picker/preview-document-picker.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/common/document-picker/preview-document-picker.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 @@ -3144,11 +3134,6 @@ "count": 4 } }, - "app/components/datasets/common/retrieval-method-config/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/common/retrieval-method-info/index.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -3247,11 +3232,6 @@ "count": 1 } }, - "app/components/datasets/create/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 16 - } - }, "app/components/datasets/create/notion-page-preview/index.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -3270,11 +3250,6 @@ "count": 1 } }, - "app/components/datasets/create/step-three/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 7 - } - }, "app/components/datasets/create/step-three/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 7 @@ -3330,11 +3305,6 @@ "count": 2 } }, - "app/components/datasets/create/stop-embedding-modal/index.spec.tsx": { - "test/prefer-hooks-in-order": { - "count": 1 - } - }, "app/components/datasets/create/top-bar/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -3442,11 +3412,6 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -3457,31 +3422,16 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 10 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -3492,11 +3442,6 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 @@ -3532,11 +3477,6 @@ "count": 3 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -3547,11 +3487,6 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 @@ -3562,11 +3497,6 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 11 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 5 @@ -3612,11 +3542,6 @@ "count": 2 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 9 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -3625,11 +3550,6 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 11 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx": { "ts/no-explicit-any": { "count": 2 From d6b025e91e3fdcbcb1d5ba2173acc12aca1523a2 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 12 Feb 2026 10:04:56 +0800 Subject: [PATCH 04/18] test(web): add comprehensive unit and integration tests for plugins and tools modules (#32220) Co-authored-by: CodingOnStar <hanxujiang@dify.com> --- .../plugins/plugin-auth-flow.test.tsx | 271 +++ .../plugins/plugin-card-rendering.test.tsx | 224 ++ .../plugins/plugin-data-utilities.test.ts | 159 ++ .../plugins/plugin-install-flow.test.ts | 269 +++ .../plugin-marketplace-to-install.test.tsx | 97 + .../plugin-page-filter-management.test.tsx | 120 + .../tool-browsing-and-filtering.test.tsx | 369 +++ .../tools/tool-data-processing.test.ts | 239 ++ .../tools/tool-provider-detail-flow.test.tsx | 548 +++++ .../plugins/{ => __tests__}/hooks.spec.ts | 134 +- .../plugins/__tests__/utils.spec.ts | 50 + .../__tests__/deprecation-notice.spec.tsx | 92 + .../base/__tests__/key-value-item.spec.tsx | 59 + .../icon-with-tooltip.spec.tsx | 2 +- .../badges/{ => __tests__}/partner.spec.tsx | 6 +- .../base/badges/__tests__/verified.spec.tsx | 52 + .../card/__tests__/card-more-info.spec.tsx | 50 + .../plugins/card/__tests__/index.spec.tsx | 589 +++++ .../card/base/__tests__/card-icon.spec.tsx | 61 + .../card/base/__tests__/corner-mark.spec.tsx | 27 + .../card/base/__tests__/description.spec.tsx | 37 + .../base/__tests__/download-count.spec.tsx | 28 + .../card/base/__tests__/org-info.spec.tsx | 34 + .../card/base/__tests__/placeholder.spec.tsx | 71 + .../card/base/__tests__/title.spec.tsx | 21 + .../components/plugins/card/index.spec.tsx | 1877 --------------- .../install-plugin/__tests__/hooks.spec.ts | 166 ++ .../{ => __tests__}/utils.spec.ts | 6 +- .../base/__tests__/check-task-status.spec.ts | 125 + .../base/__tests__/installed.spec.tsx | 81 + .../base/__tests__/loading-error.spec.tsx | 46 + .../base/__tests__/loading.spec.tsx | 29 + .../base/__tests__/version.spec.tsx | 43 + .../__tests__/use-check-installed.spec.tsx | 79 + .../hooks/__tests__/use-hide-logic.spec.ts | 76 + .../use-install-plugin-limit.spec.ts | 149 ++ .../__tests__/use-refresh-plugin-list.spec.ts | 168 ++ .../{ => __tests__}/index.spec.tsx | 32 +- .../{ => __tests__}/install-multi.spec.tsx | 16 +- .../steps/{ => __tests__}/install.spec.tsx | 14 +- .../{ => __tests__}/index.spec.tsx | 22 +- .../steps/{ => __tests__}/loaded.spec.tsx | 12 +- .../{ => __tests__}/selectPackage.spec.tsx | 8 +- .../steps/{ => __tests__}/setURL.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 14 +- .../{ => __tests__}/ready-to-install.spec.tsx | 12 +- .../steps/{ => __tests__}/install.spec.tsx | 31 +- .../steps/{ => __tests__}/uploading.spec.tsx | 8 +- .../{ => __tests__}/index.spec.tsx | 16 +- .../steps/{ => __tests__}/install.spec.tsx | 28 +- .../marketplace/__tests__/hooks.spec.tsx | 601 +++++ .../marketplace/__tests__/index.spec.tsx | 15 + .../marketplace/__tests__/utils.spec.ts | 317 +++ .../{ => __tests__}/index.spec.tsx | 2 +- .../empty/{ => __tests__}/index.spec.tsx | 4 +- .../plugins/marketplace/hooks.spec.tsx | 597 +++++ .../plugins/marketplace/index.spec.tsx | 1828 --------------- .../list/{ => __tests__}/index.spec.tsx | 38 +- .../search-box/{ => __tests__}/index.spec.tsx | 10 +- .../{ => __tests__}/index.spec.tsx | 6 +- .../authorized-in-data-source-node.spec.tsx | 45 + .../__tests__/authorized-in-node.spec.tsx | 210 ++ .../plugin-auth/__tests__/index.spec.tsx | 247 ++ .../__tests__/plugin-auth-in-agent.spec.tsx | 255 +++ .../plugin-auth-in-datasource-node.spec.tsx | 51 + .../__tests__/plugin-auth.spec.tsx | 139 ++ .../plugin-auth/__tests__/utils.spec.ts | 55 + .../__tests__/add-api-key-button.spec.tsx | 67 + .../__tests__/add-oauth-button.spec.tsx | 102 + .../__tests__/api-key-modal.spec.tsx | 165 ++ .../authorize-components.spec.tsx | 30 +- .../authorize/{ => __tests__}/index.spec.tsx | 10 +- .../__tests__/oauth-client-settings.spec.tsx | 179 ++ .../authorized/{ => __tests__}/index.spec.tsx | 10 +- .../authorized/{ => __tests__}/item.spec.tsx | 8 +- .../hooks/__tests__/use-credential.spec.ts | 186 ++ .../hooks/__tests__/use-get-api.spec.ts | 80 + .../__tests__/use-plugin-auth-action.spec.ts | 191 ++ .../hooks/__tests__/use-plugin-auth.spec.ts | 110 + .../plugins/plugin-auth/index.spec.tsx | 2035 ----------------- .../{ => __tests__}/action-list.spec.tsx | 17 +- .../agent-strategy-list.spec.tsx | 16 +- .../datasource-action-list.spec.tsx | 16 +- .../{ => __tests__}/detail-header.spec.tsx | 62 +- .../{ => __tests__}/endpoint-card.spec.tsx | 40 +- .../{ => __tests__}/endpoint-list.spec.tsx | 18 +- .../{ => __tests__}/endpoint-modal.spec.tsx | 53 +- .../{ => __tests__}/index.spec.tsx | 22 +- .../{ => __tests__}/model-list.spec.tsx | 16 +- .../operation-dropdown.spec.tsx | 41 +- .../{ => __tests__}/store.spec.ts | 4 +- .../{ => __tests__}/strategy-detail.spec.tsx | 16 +- .../{ => __tests__}/strategy-item.spec.tsx | 4 +- .../{ => __tests__}/utils.spec.ts | 2 +- .../__tests__/app-trigger.spec.tsx | 46 + .../{ => __tests__}/index.spec.tsx | 10 +- .../{ => __tests__}/header-modals.spec.tsx | 16 +- .../plugin-source-badge.spec.tsx | 26 +- .../use-detail-header-state.spec.ts | 10 +- .../use-plugin-operations.spec.ts | 10 +- .../{ => __tests__}/index.spec.tsx | 6 +- .../{ => __tests__}/llm-params-panel.spec.tsx | 2 +- .../{ => __tests__}/tts-params-panel.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 16 +- .../{ => __tests__}/delete-confirm.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 8 +- .../{ => __tests__}/list-view.spec.tsx | 6 +- .../{ => __tests__}/log-viewer.spec.tsx | 2 +- .../{ => __tests__}/selector-entry.spec.tsx | 6 +- .../{ => __tests__}/selector-view.spec.tsx | 6 +- .../subscription-card.spec.tsx | 6 +- .../use-subscription-list.spec.ts | 6 +- .../{ => __tests__}/common-modal.spec.tsx | 8 +- .../create/{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/oauth-client.spec.tsx | 4 +- .../use-oauth-client-state.spec.ts | 2 +- .../apikey-edit-modal.spec.tsx | 6 +- .../edit/{ => __tests__}/index.spec.tsx | 12 +- .../manual-edit-modal.spec.tsx | 6 +- .../{ => __tests__}/oauth-edit-modal.spec.tsx | 6 +- .../{ => __tests__}/index.spec.tsx | 10 +- .../__tests__/tool-base-form.spec.tsx | 107 + .../__tests__/tool-credentials-form.spec.tsx | 113 + .../use-plugin-installed-check.spec.ts | 63 + .../__tests__/use-tool-selector-state.spec.ts | 226 ++ .../event-detail-drawer.spec.tsx | 32 +- .../{ => __tests__}/event-list.spec.tsx | 20 +- .../{ => __tests__}/action.spec.tsx | 16 +- .../{ => __tests__}/index.spec.tsx | 66 +- .../{ => __tests__}/index.spec.tsx | 90 +- .../{ => __tests__}/context.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 14 +- .../__tests__/plugin-info.spec.tsx | 75 + .../use-reference-setting.spec.ts | 17 +- .../{ => __tests__}/use-uploader.spec.ts | 2 +- .../empty/{ => __tests__}/index.spec.tsx | 89 +- .../__tests__/category-filter.spec.tsx | 100 + .../{ => __tests__}/index.spec.tsx | 18 +- .../__tests__/search-box.spec.tsx | 32 + .../filter-management/__tests__/store.spec.ts | 85 + .../list/{ => __tests__}/index.spec.tsx | 16 +- .../plugin-tasks/__tests__/hooks.spec.ts | 77 + .../{ => __tests__}/index.spec.tsx | 18 +- .../readme-panel/__tests__/constants.spec.ts | 20 + .../readme-panel/__tests__/entrance.spec.tsx | 67 + .../{ => __tests__}/index.spec.tsx | 299 +-- .../readme-panel/__tests__/store.spec.ts | 54 + .../{ => __tests__}/index.spec.tsx | 284 +-- .../__tests__/label.spec.tsx | 97 + .../{ => __tests__}/index.spec.tsx | 226 +- .../{ => __tests__}/utils.spec.ts | 2 +- .../__tests__/downgrade-warning.spec.tsx | 78 + .../__tests__/from-github.spec.tsx | 51 + .../{ => __tests__}/index.spec.tsx | 131 +- .../tools/__tests__/provider-list.spec.tsx | 263 +++ .../config-credentials.spec.tsx | 2 +- .../{ => __tests__}/get-schema.spec.tsx | 6 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/test-api.spec.tsx | 2 +- .../labels/{ => __tests__}/filter.spec.tsx | 2 +- .../labels/{ => __tests__}/selector.spec.tsx | 2 +- .../tools/labels/__tests__/store.spec.ts | 41 + .../tools/marketplace/__tests__/hooks.spec.ts | 201 ++ .../marketplace/__tests__/index.spec.tsx | 180 ++ .../tools/marketplace/index.spec.tsx | 360 --- .../mcp/{ => __tests__}/create-card.spec.tsx | 4 +- .../{ => __tests__}/headers-input.spec.tsx | 2 +- .../tools/mcp/{ => __tests__}/index.spec.tsx | 8 +- .../{ => __tests__}/mcp-server-modal.spec.tsx | 2 +- .../mcp-server-param-item.spec.tsx | 2 +- .../{ => __tests__}/mcp-service-card.spec.tsx | 4 +- .../tools/mcp/{ => __tests__}/modal.spec.tsx | 2 +- .../{ => __tests__}/provider-card.spec.tsx | 6 +- .../detail/{ => __tests__}/content.spec.tsx | 8 +- .../{ => __tests__}/list-loading.spec.tsx | 2 +- .../operation-dropdown.spec.tsx | 2 +- .../{ => __tests__}/provider-detail.spec.tsx | 4 +- .../detail/{ => __tests__}/tool-item.spec.tsx | 2 +- .../use-mcp-modal-form.spec.ts | 2 +- .../use-mcp-service-card.spec.ts | 2 +- .../authentication-section.spec.tsx | 2 +- .../configurations-section.spec.tsx | 2 +- .../{ => __tests__}/headers-section.spec.tsx | 2 +- .../custom-create-card.spec.tsx | 6 +- .../tools/provider/__tests__/detail.spec.tsx | 713 ++++++ .../provider/{ => __tests__}/empty.spec.tsx | 4 +- .../{ => __tests__}/tool-item.spec.tsx | 4 +- .../__tests__/config-credentials.spec.tsx | 188 ++ .../tools/utils/__tests__/index.spec.ts | 82 + .../utils/__tests__/to-form-schema.spec.ts | 408 ++++ .../{ => __tests__}/configure-button.spec.tsx | 8 +- .../{ => __tests__}/method-selector.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 2 +- web/eslint-suppressions.json | 66 - web/test/i18n-mock.ts | 25 +- 195 files changed, 12219 insertions(+), 7840 deletions(-) create mode 100644 web/__tests__/plugins/plugin-auth-flow.test.tsx create mode 100644 web/__tests__/plugins/plugin-card-rendering.test.tsx create mode 100644 web/__tests__/plugins/plugin-data-utilities.test.ts create mode 100644 web/__tests__/plugins/plugin-install-flow.test.ts create mode 100644 web/__tests__/plugins/plugin-marketplace-to-install.test.tsx create mode 100644 web/__tests__/plugins/plugin-page-filter-management.test.tsx create mode 100644 web/__tests__/tools/tool-browsing-and-filtering.test.tsx create mode 100644 web/__tests__/tools/tool-data-processing.test.ts create mode 100644 web/__tests__/tools/tool-provider-detail-flow.test.tsx rename web/app/components/plugins/{ => __tests__}/hooks.spec.ts (70%) create mode 100644 web/app/components/plugins/__tests__/utils.spec.ts create mode 100644 web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx create mode 100644 web/app/components/plugins/base/__tests__/key-value-item.spec.tsx rename web/app/components/plugins/base/badges/{ => __tests__}/icon-with-tooltip.spec.tsx (99%) rename web/app/components/plugins/base/badges/{ => __tests__}/partner.spec.tsx (97%) create mode 100644 web/app/components/plugins/base/badges/__tests__/verified.spec.tsx create mode 100644 web/app/components/plugins/card/__tests__/card-more-info.spec.tsx create mode 100644 web/app/components/plugins/card/__tests__/index.spec.tsx create mode 100644 web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx create mode 100644 web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx create mode 100644 web/app/components/plugins/card/base/__tests__/description.spec.tsx create mode 100644 web/app/components/plugins/card/base/__tests__/download-count.spec.tsx create mode 100644 web/app/components/plugins/card/base/__tests__/org-info.spec.tsx create mode 100644 web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx create mode 100644 web/app/components/plugins/card/base/__tests__/title.spec.tsx delete mode 100644 web/app/components/plugins/card/index.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts rename web/app/components/plugins/install-plugin/{ => __tests__}/utils.spec.ts (99%) create mode 100644 web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts create mode 100644 web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/base/__tests__/loading.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/hooks/__tests__/use-hide-logic.spec.ts create mode 100644 web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts create mode 100644 web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts rename web/app/components/plugins/install-plugin/install-bundle/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/plugins/install-plugin/install-bundle/steps/{ => __tests__}/install-multi.spec.tsx (98%) rename web/app/components/plugins/install-plugin/install-bundle/steps/{ => __tests__}/install.spec.tsx (98%) rename web/app/components/plugins/install-plugin/install-from-github/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/plugins/install-plugin/install-from-github/steps/{ => __tests__}/loaded.spec.tsx (98%) rename web/app/components/plugins/install-plugin/install-from-github/steps/{ => __tests__}/selectPackage.spec.tsx (99%) rename web/app/components/plugins/install-plugin/install-from-github/steps/{ => __tests__}/setURL.spec.tsx (99%) rename web/app/components/plugins/install-plugin/install-from-local-package/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/plugins/install-plugin/install-from-local-package/{ => __tests__}/ready-to-install.spec.tsx (98%) rename web/app/components/plugins/install-plugin/install-from-local-package/steps/{ => __tests__}/install.spec.tsx (95%) rename web/app/components/plugins/install-plugin/install-from-local-package/steps/{ => __tests__}/uploading.spec.tsx (98%) rename web/app/components/plugins/install-plugin/install-from-marketplace/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/plugins/install-plugin/install-from-marketplace/steps/{ => __tests__}/install.spec.tsx (96%) create mode 100644 web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/index.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/utils.spec.ts rename web/app/components/plugins/marketplace/description/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/plugins/marketplace/empty/{ => __tests__}/index.spec.tsx (99%) create mode 100644 web/app/components/plugins/marketplace/hooks.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/index.spec.tsx rename web/app/components/plugins/marketplace/list/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/plugins/marketplace/search-box/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/plugins/marketplace/sort-dropdown/{ => __tests__}/index.spec.tsx (99%) create mode 100644 web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx create mode 100644 web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx create mode 100644 web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx create mode 100644 web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx create mode 100644 web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx create mode 100644 web/app/components/plugins/plugin-auth/__tests__/utils.spec.ts create mode 100644 web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx rename web/app/components/plugins/plugin-auth/authorize/{ => __tests__}/authorize-components.spec.tsx (98%) rename web/app/components/plugins/plugin-auth/authorize/{ => __tests__}/index.spec.tsx (98%) create mode 100644 web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx rename web/app/components/plugins/plugin-auth/authorized/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/plugins/plugin-auth/authorized/{ => __tests__}/item.spec.tsx (99%) create mode 100644 web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts create mode 100644 web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts create mode 100644 web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts create mode 100644 web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth.spec.ts delete mode 100644 web/app/components/plugins/plugin-auth/index.spec.tsx rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/action-list.spec.tsx (88%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/agent-strategy-list.spec.tsx (88%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/datasource-action-list.spec.tsx (85%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/detail-header.spec.tsx (94%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/endpoint-card.spec.tsx (89%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/endpoint-list.spec.tsx (94%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/endpoint-modal.spec.tsx (88%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/model-list.spec.tsx (87%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/operation-dropdown.spec.tsx (81%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/store.spec.ts (99%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/strategy-detail.spec.tsx (93%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/strategy-item.spec.tsx (97%) rename web/app/components/plugins/plugin-detail-panel/{ => __tests__}/utils.spec.ts (98%) create mode 100644 web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx rename web/app/components/plugins/plugin-detail-panel/app-selector/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/plugins/plugin-detail-panel/detail-header/components/{ => __tests__}/header-modals.spec.tsx (98%) rename web/app/components/plugins/plugin-detail-panel/detail-header/components/{ => __tests__}/plugin-source-badge.spec.tsx (89%) rename web/app/components/plugins/plugin-detail-panel/detail-header/hooks/{ => __tests__}/use-detail-header-state.spec.ts (97%) rename web/app/components/plugins/plugin-detail-panel/detail-header/hooks/{ => __tests__}/use-plugin-operations.spec.ts (98%) rename web/app/components/plugins/plugin-detail-panel/model-selector/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/plugins/plugin-detail-panel/model-selector/{ => __tests__}/llm-params-panel.spec.tsx (99%) rename web/app/components/plugins/plugin-detail-panel/model-selector/{ => __tests__}/tts-params-panel.spec.tsx (99%) rename web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/{ => __tests__}/delete-confirm.spec.tsx (96%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/{ => __tests__}/list-view.spec.tsx (93%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/{ => __tests__}/log-viewer.spec.tsx (99%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/{ => __tests__}/selector-entry.spec.tsx (95%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/{ => __tests__}/selector-view.spec.tsx (97%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/{ => __tests__}/subscription-card.spec.tsx (95%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/{ => __tests__}/use-subscription-list.spec.ts (93%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/create/{ => __tests__}/common-modal.spec.tsx (99%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/create/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/create/{ => __tests__}/oauth-client.spec.tsx (99%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/{ => __tests__}/use-oauth-client-state.spec.ts (99%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/edit/{ => __tests__}/apikey-edit-modal.spec.tsx (95%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/edit/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/edit/{ => __tests__}/manual-edit-modal.spec.tsx (95%) rename web/app/components/plugins/plugin-detail-panel/subscription-list/edit/{ => __tests__}/oauth-edit-modal.spec.tsx (95%) rename web/app/components/plugins/plugin-detail-panel/tool-selector/{ => __tests__}/index.spec.tsx (99%) create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts rename web/app/components/plugins/plugin-detail-panel/trigger/{ => __tests__}/event-detail-drawer.spec.tsx (89%) rename web/app/components/plugins/plugin-detail-panel/trigger/{ => __tests__}/event-list.spec.tsx (88%) rename web/app/components/plugins/plugin-item/{ => __tests__}/action.spec.tsx (98%) rename web/app/components/plugins/plugin-item/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/plugins/plugin-mutation-model/{ => __tests__}/index.spec.tsx (92%) rename web/app/components/plugins/plugin-page/{ => __tests__}/context.spec.tsx (98%) rename web/app/components/plugins/plugin-page/{ => __tests__}/index.spec.tsx (99%) create mode 100644 web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx rename web/app/components/plugins/plugin-page/{ => __tests__}/use-reference-setting.spec.ts (97%) rename web/app/components/plugins/plugin-page/{ => __tests__}/use-uploader.spec.ts (99%) rename web/app/components/plugins/plugin-page/empty/{ => __tests__}/index.spec.tsx (86%) create mode 100644 web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx rename web/app/components/plugins/plugin-page/filter-management/{ => __tests__}/index.spec.tsx (98%) create mode 100644 web/app/components/plugins/plugin-page/filter-management/__tests__/search-box.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/filter-management/__tests__/store.spec.ts rename web/app/components/plugins/plugin-page/list/{ => __tests__}/index.spec.tsx (97%) create mode 100644 web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts rename web/app/components/plugins/plugin-page/plugin-tasks/{ => __tests__}/index.spec.tsx (97%) create mode 100644 web/app/components/plugins/readme-panel/__tests__/constants.spec.ts create mode 100644 web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx rename web/app/components/plugins/readme-panel/{ => __tests__}/index.spec.tsx (67%) create mode 100644 web/app/components/plugins/readme-panel/__tests__/store.spec.ts rename web/app/components/plugins/reference-setting-modal/{ => __tests__}/index.spec.tsx (75%) create mode 100644 web/app/components/plugins/reference-setting-modal/__tests__/label.spec.tsx rename web/app/components/plugins/reference-setting-modal/auto-update-setting/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/plugins/reference-setting-modal/auto-update-setting/{ => __tests__}/utils.spec.ts (94%) create mode 100644 web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx create mode 100644 web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx rename web/app/components/plugins/update-plugin/{ => __tests__}/index.spec.tsx (87%) create mode 100644 web/app/components/tools/__tests__/provider-list.spec.tsx rename web/app/components/tools/edit-custom-collection-modal/{ => __tests__}/config-credentials.spec.tsx (99%) rename web/app/components/tools/edit-custom-collection-modal/{ => __tests__}/get-schema.spec.tsx (94%) rename web/app/components/tools/edit-custom-collection-modal/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/tools/edit-custom-collection-modal/{ => __tests__}/test-api.spec.tsx (99%) rename web/app/components/tools/labels/{ => __tests__}/filter.spec.tsx (99%) rename web/app/components/tools/labels/{ => __tests__}/selector.spec.tsx (99%) create mode 100644 web/app/components/tools/labels/__tests__/store.spec.ts create mode 100644 web/app/components/tools/marketplace/__tests__/hooks.spec.ts create mode 100644 web/app/components/tools/marketplace/__tests__/index.spec.tsx delete mode 100644 web/app/components/tools/marketplace/index.spec.tsx rename web/app/components/tools/mcp/{ => __tests__}/create-card.spec.tsx (98%) rename web/app/components/tools/mcp/{ => __tests__}/headers-input.spec.tsx (99%) rename web/app/components/tools/mcp/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/tools/mcp/{ => __tests__}/mcp-server-modal.spec.tsx (99%) rename web/app/components/tools/mcp/{ => __tests__}/mcp-server-param-item.spec.tsx (99%) rename web/app/components/tools/mcp/{ => __tests__}/mcp-service-card.spec.tsx (99%) rename web/app/components/tools/mcp/{ => __tests__}/modal.spec.tsx (99%) rename web/app/components/tools/mcp/{ => __tests__}/provider-card.spec.tsx (99%) rename web/app/components/tools/mcp/detail/{ => __tests__}/content.spec.tsx (99%) rename web/app/components/tools/mcp/detail/{ => __tests__}/list-loading.spec.tsx (98%) rename web/app/components/tools/mcp/detail/{ => __tests__}/operation-dropdown.spec.tsx (99%) rename web/app/components/tools/mcp/detail/{ => __tests__}/provider-detail.spec.tsx (98%) rename web/app/components/tools/mcp/detail/{ => __tests__}/tool-item.spec.tsx (99%) rename web/app/components/tools/mcp/hooks/{ => __tests__}/use-mcp-modal-form.spec.ts (99%) rename web/app/components/tools/mcp/hooks/{ => __tests__}/use-mcp-service-card.spec.ts (99%) rename web/app/components/tools/mcp/sections/{ => __tests__}/authentication-section.spec.tsx (98%) rename web/app/components/tools/mcp/sections/{ => __tests__}/configurations-section.spec.tsx (98%) rename web/app/components/tools/mcp/sections/{ => __tests__}/headers-section.spec.tsx (99%) rename web/app/components/tools/provider/{ => __tests__}/custom-create-card.spec.tsx (98%) create mode 100644 web/app/components/tools/provider/__tests__/detail.spec.tsx rename web/app/components/tools/provider/{ => __tests__}/empty.spec.tsx (98%) rename web/app/components/tools/provider/{ => __tests__}/tool-item.spec.tsx (99%) create mode 100644 web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx create mode 100644 web/app/components/tools/utils/__tests__/index.spec.ts create mode 100644 web/app/components/tools/utils/__tests__/to-form-schema.spec.ts rename web/app/components/tools/workflow-tool/{ => __tests__}/configure-button.spec.tsx (99%) rename web/app/components/tools/workflow-tool/{ => __tests__}/method-selector.spec.tsx (99%) rename web/app/components/tools/workflow-tool/confirm-modal/{ => __tests__}/index.spec.tsx (99%) diff --git a/web/__tests__/plugins/plugin-auth-flow.test.tsx b/web/__tests__/plugins/plugin-auth-flow.test.tsx new file mode 100644 index 0000000000..a2ec8703ca --- /dev/null +++ b/web/__tests__/plugins/plugin-auth-flow.test.tsx @@ -0,0 +1,271 @@ +/** + * 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') + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-card-rendering.test.tsx b/web/__tests__/plugins/plugin-card-rendering.test.tsx new file mode 100644 index 0000000000..7abcb01b49 --- /dev/null +++ b/web/__tests__/plugins/plugin-card-rendering.test.tsx @@ -0,0 +1,224 @@ +/** + * 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') + }) +}) diff --git a/web/__tests__/plugins/plugin-data-utilities.test.ts b/web/__tests__/plugins/plugin-data-utilities.test.ts new file mode 100644 index 0000000000..068b0e3238 --- /dev/null +++ b/web/__tests__/plugins/plugin-data-utilities.test.ts @@ -0,0 +1,159 @@ +/** + * 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') + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-install-flow.test.ts b/web/__tests__/plugins/plugin-install-flow.test.ts new file mode 100644 index 0000000000..7ceca4535b --- /dev/null +++ b/web/__tests__/plugins/plugin-install-flow.test.ts @@ -0,0 +1,269 @@ +/** + * 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) + + await vi.doMock('@/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') + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx new file mode 100644 index 0000000000..91e32155e7 --- /dev/null +++ b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx @@ -0,0 +1,97 @@ +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) + }) + }) +}) diff --git a/web/__tests__/plugins/plugin-page-filter-management.test.tsx b/web/__tests__/plugins/plugin-page-filter-management.test.tsx new file mode 100644 index 0000000000..9f6fbabc31 --- /dev/null +++ b/web/__tests__/plugins/plugin-page-filter-management.test.tsx @@ -0,0 +1,120 @@ +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) + }) + }) +}) diff --git a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx new file mode 100644 index 0000000000..4e7fa4952b --- /dev/null +++ b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx @@ -0,0 +1,369 @@ +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() + }) +}) diff --git a/web/__tests__/tools/tool-data-processing.test.ts b/web/__tests__/tools/tool-data-processing.test.ts new file mode 100644 index 0000000000..120461201f --- /dev/null +++ b/web/__tests__/tools/tool-data-processing.test.ts @@ -0,0 +1,239 @@ +/** + * 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) + }) + }) +}) diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx new file mode 100644 index 0000000000..0101f83f22 --- /dev/null +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -0,0 +1,548 @@ +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() + }) + }) +}) diff --git a/web/app/components/plugins/hooks.spec.ts b/web/app/components/plugins/__tests__/hooks.spec.ts similarity index 70% rename from web/app/components/plugins/hooks.spec.ts rename to web/app/components/plugins/__tests__/hooks.spec.ts index 079d4de831..a8a8c43102 100644 --- a/web/app/components/plugins/hooks.spec.ts +++ b/web/app/components/plugins/__tests__/hooks.spec.ts @@ -1,59 +1,10 @@ import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PLUGIN_PAGE_TABS_MAP, useCategories, usePluginPageTabs, useTags } from './hooks' - -// Create mock translation function -const mockT = vi.fn((key: string, _options?: Record<string, string>) => { - const translations: Record<string, string> = { - 'tags.agent': 'Agent', - 'tags.rag': 'RAG', - 'tags.search': 'Search', - 'tags.image': 'Image', - 'tags.videos': 'Videos', - 'tags.weather': 'Weather', - 'tags.finance': 'Finance', - 'tags.design': 'Design', - 'tags.travel': 'Travel', - 'tags.social': 'Social', - 'tags.news': 'News', - 'tags.medical': 'Medical', - 'tags.productivity': 'Productivity', - 'tags.education': 'Education', - 'tags.business': 'Business', - 'tags.entertainment': 'Entertainment', - 'tags.utilities': 'Utilities', - 'tags.other': 'Other', - 'category.models': 'Models', - 'category.tools': 'Tools', - 'category.datasources': 'Datasources', - 'category.agents': 'Agents', - 'category.extensions': 'Extensions', - 'category.bundles': 'Bundles', - 'category.triggers': 'Triggers', - 'categorySingle.model': 'Model', - 'categorySingle.tool': 'Tool', - 'categorySingle.datasource': 'Datasource', - 'categorySingle.agent': 'Agent', - 'categorySingle.extension': 'Extension', - 'categorySingle.bundle': 'Bundle', - 'categorySingle.trigger': 'Trigger', - 'menus.plugins': 'Plugins', - 'menus.exploreMarketplace': 'Explore Marketplace', - } - return translations[key] || key -}) - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: mockT, - }), -})) +import { PLUGIN_PAGE_TABS_MAP, useCategories, usePluginPageTabs, useTags } from '../hooks' describe('useTags', () => { beforeEach(() => { vi.clearAllMocks() - mockT.mockClear() }) describe('Rendering', () => { @@ -65,13 +16,12 @@ describe('useTags', () => { expect(result.current.tags.length).toBeGreaterThan(0) }) - it('should call translation function for each tag', () => { - renderHook(() => useTags()) + it('should return tags with translated labels', () => { + const { result } = renderHook(() => useTags()) - // Verify t() was called for tag translations - expect(mockT).toHaveBeenCalled() - const tagCalls = mockT.mock.calls.filter(call => call[0].startsWith('tags.')) - expect(tagCalls.length).toBeGreaterThan(0) + result.current.tags.forEach((tag) => { + expect(tag.label).toBe(`pluginTags.tags.${tag.name}`) + }) }) it('should return tags with name and label properties', () => { @@ -99,7 +49,7 @@ describe('useTags', () => { expect(result.current.tagsMap.agent).toBeDefined() expect(result.current.tagsMap.agent.name).toBe('agent') - expect(result.current.tagsMap.agent.label).toBe('Agent') + expect(result.current.tagsMap.agent.label).toBe('pluginTags.tags.agent') }) it('should contain all tags from tags array', () => { @@ -116,9 +66,8 @@ describe('useTags', () => { it('should return label for existing tag', () => { const { result } = renderHook(() => useTags()) - // Test existing tags - this covers the branch where tagsMap[name] exists - expect(result.current.getTagLabel('agent')).toBe('Agent') - expect(result.current.getTagLabel('search')).toBe('Search') + expect(result.current.getTagLabel('agent')).toBe('pluginTags.tags.agent') + expect(result.current.getTagLabel('search')).toBe('pluginTags.tags.search') }) it('should return name for non-existing tag', () => { @@ -132,11 +81,9 @@ describe('useTags', () => { it('should cover both branches of getTagLabel conditional', () => { const { result } = renderHook(() => useTags()) - // Branch 1: tag exists in tagsMap - returns label const existingTagResult = result.current.getTagLabel('rag') - expect(existingTagResult).toBe('RAG') + expect(existingTagResult).toBe('pluginTags.tags.rag') - // Branch 2: tag does not exist in tagsMap - returns name itself const nonExistingTagResult = result.current.getTagLabel('unknown-tag-xyz') expect(nonExistingTagResult).toBe('unknown-tag-xyz') }) @@ -150,23 +97,22 @@ describe('useTags', () => { it('should return correct labels for all predefined tags', () => { const { result } = renderHook(() => useTags()) - // Test all predefined tags - expect(result.current.getTagLabel('rag')).toBe('RAG') - expect(result.current.getTagLabel('image')).toBe('Image') - expect(result.current.getTagLabel('videos')).toBe('Videos') - expect(result.current.getTagLabel('weather')).toBe('Weather') - expect(result.current.getTagLabel('finance')).toBe('Finance') - expect(result.current.getTagLabel('design')).toBe('Design') - expect(result.current.getTagLabel('travel')).toBe('Travel') - expect(result.current.getTagLabel('social')).toBe('Social') - expect(result.current.getTagLabel('news')).toBe('News') - expect(result.current.getTagLabel('medical')).toBe('Medical') - expect(result.current.getTagLabel('productivity')).toBe('Productivity') - expect(result.current.getTagLabel('education')).toBe('Education') - expect(result.current.getTagLabel('business')).toBe('Business') - expect(result.current.getTagLabel('entertainment')).toBe('Entertainment') - expect(result.current.getTagLabel('utilities')).toBe('Utilities') - expect(result.current.getTagLabel('other')).toBe('Other') + expect(result.current.getTagLabel('rag')).toBe('pluginTags.tags.rag') + expect(result.current.getTagLabel('image')).toBe('pluginTags.tags.image') + expect(result.current.getTagLabel('videos')).toBe('pluginTags.tags.videos') + expect(result.current.getTagLabel('weather')).toBe('pluginTags.tags.weather') + expect(result.current.getTagLabel('finance')).toBe('pluginTags.tags.finance') + expect(result.current.getTagLabel('design')).toBe('pluginTags.tags.design') + expect(result.current.getTagLabel('travel')).toBe('pluginTags.tags.travel') + expect(result.current.getTagLabel('social')).toBe('pluginTags.tags.social') + expect(result.current.getTagLabel('news')).toBe('pluginTags.tags.news') + expect(result.current.getTagLabel('medical')).toBe('pluginTags.tags.medical') + expect(result.current.getTagLabel('productivity')).toBe('pluginTags.tags.productivity') + expect(result.current.getTagLabel('education')).toBe('pluginTags.tags.education') + expect(result.current.getTagLabel('business')).toBe('pluginTags.tags.business') + expect(result.current.getTagLabel('entertainment')).toBe('pluginTags.tags.entertainment') + expect(result.current.getTagLabel('utilities')).toBe('pluginTags.tags.utilities') + expect(result.current.getTagLabel('other')).toBe('pluginTags.tags.other') }) it('should handle empty string tag name', () => { @@ -255,27 +201,27 @@ describe('useCategories', () => { it('should use plural labels when isSingle is false', () => { const { result } = renderHook(() => useCategories(false)) - expect(result.current.categoriesMap.tool.label).toBe('Tools') + expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools') }) it('should use plural labels when isSingle is undefined', () => { const { result } = renderHook(() => useCategories()) - expect(result.current.categoriesMap.tool.label).toBe('Tools') + expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools') }) it('should use singular labels when isSingle is true', () => { const { result } = renderHook(() => useCategories(true)) - expect(result.current.categoriesMap.tool.label).toBe('Tool') + expect(result.current.categoriesMap.tool.label).toBe('plugin.categorySingle.tool') }) it('should handle agent category specially', () => { const { result: resultPlural } = renderHook(() => useCategories(false)) const { result: resultSingle } = renderHook(() => useCategories(true)) - expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('Agents') - expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('Agent') + expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('plugin.category.agents') + expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('plugin.categorySingle.agent') }) }) @@ -298,7 +244,6 @@ describe('useCategories', () => { describe('usePluginPageTabs', () => { beforeEach(() => { vi.clearAllMocks() - mockT.mockClear() }) describe('Rendering', () => { @@ -326,12 +271,11 @@ describe('usePluginPageTabs', () => { }) }) - it('should call translation function for tab texts', () => { - renderHook(() => usePluginPageTabs()) + it('should return tabs with translated texts', () => { + const { result } = renderHook(() => usePluginPageTabs()) - // Verify t() was called for menu translations - expect(mockT).toHaveBeenCalledWith('menus.plugins', { ns: 'common' }) - expect(mockT).toHaveBeenCalledWith('menus.exploreMarketplace', { ns: 'common' }) + expect(result.current[0].text).toBe('common.menus.plugins') + expect(result.current[1].text).toBe('common.menus.exploreMarketplace') }) }) @@ -342,7 +286,7 @@ describe('usePluginPageTabs', () => { const pluginsTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.plugins) expect(pluginsTab).toBeDefined() expect(pluginsTab?.value).toBe('plugins') - expect(pluginsTab?.text).toBe('Plugins') + expect(pluginsTab?.text).toBe('common.menus.plugins') }) it('should have marketplace tab with correct value', () => { @@ -351,7 +295,7 @@ describe('usePluginPageTabs', () => { const marketplaceTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.marketplace) expect(marketplaceTab).toBeDefined() expect(marketplaceTab?.value).toBe('discover') - expect(marketplaceTab?.text).toBe('Explore Marketplace') + expect(marketplaceTab?.text).toBe('common.menus.exploreMarketplace') }) }) @@ -360,14 +304,14 @@ describe('usePluginPageTabs', () => { const { result } = renderHook(() => usePluginPageTabs()) expect(result.current[0].value).toBe('plugins') - expect(result.current[0].text).toBe('Plugins') + expect(result.current[0].text).toBe('common.menus.plugins') }) it('should return marketplace tab as second tab', () => { const { result } = renderHook(() => usePluginPageTabs()) expect(result.current[1].value).toBe('discover') - expect(result.current[1].text).toBe('Explore Marketplace') + expect(result.current[1].text).toBe('common.menus.exploreMarketplace') }) }) diff --git a/web/app/components/plugins/__tests__/utils.spec.ts b/web/app/components/plugins/__tests__/utils.spec.ts new file mode 100644 index 0000000000..0dc166b175 --- /dev/null +++ b/web/app/components/plugins/__tests__/utils.spec.ts @@ -0,0 +1,50 @@ +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() + }) + }) +}) diff --git a/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx b/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx new file mode 100644 index 0000000000..42616f3138 --- /dev/null +++ b/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx @@ -0,0 +1,92 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DeprecationNotice from '../deprecation-notice' + +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.getByText('plugin.detailPanel.deprecation.noReason')).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.getByText('detailPanel.deprecation.fullMessage')).toBeInTheDocument() + }) + + it('renders only reason without alternative plugin', () => { + render( + <DeprecationNotice + status="deleted" + deprecatedReason="no_maintainer" + alternativePluginId="" + alternativePluginURL="" + />, + ) + expect(screen.getByText(/plugin\.detailPanel\.deprecation\.onlyReason/)).toBeInTheDocument() + }) + + it('renders no-reason message for invalid reason', () => { + render( + <DeprecationNotice + status="deleted" + deprecatedReason="unknown_reason" + alternativePluginId="" + alternativePluginURL="" + />, + ) + expect(screen.getByText('plugin.detailPanel.deprecation.noReason')).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') + }) +}) diff --git a/web/app/components/plugins/base/__tests__/key-value-item.spec.tsx b/web/app/components/plugins/base/__tests__/key-value-item.spec.tsx new file mode 100644 index 0000000000..4b3869e616 --- /dev/null +++ b/web/app/components/plugins/base/__tests__/key-value-item.spec.tsx @@ -0,0 +1,59 @@ +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('../../../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> + ), +})) + +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('renders copy tooltip', () => { + render(<KeyValueItem label="ID" value="123" />) + expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.operation.copy') + }) +}) diff --git a/web/app/components/plugins/base/badges/icon-with-tooltip.spec.tsx b/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx similarity index 99% rename from web/app/components/plugins/base/badges/icon-with-tooltip.spec.tsx rename to web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx index f1261d2984..e24aa5a873 100644 --- a/web/app/components/plugins/base/badges/icon-with-tooltip.spec.tsx +++ b/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Theme } from '@/types/app' -import IconWithTooltip from './icon-with-tooltip' +import IconWithTooltip from '../icon-with-tooltip' // Mock Tooltip component vi.mock('@/app/components/base/tooltip', () => ({ diff --git a/web/app/components/plugins/base/badges/partner.spec.tsx b/web/app/components/plugins/base/badges/__tests__/partner.spec.tsx similarity index 97% rename from web/app/components/plugins/base/badges/partner.spec.tsx rename to web/app/components/plugins/base/badges/__tests__/partner.spec.tsx index 3bdd2508fc..1685564018 100644 --- a/web/app/components/plugins/base/badges/partner.spec.tsx +++ b/web/app/components/plugins/base/badges/__tests__/partner.spec.tsx @@ -2,7 +2,7 @@ import type { ComponentProps } from 'react' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Theme } from '@/types/app' -import Partner from './partner' +import Partner from '../partner' // Mock useTheme hook const mockUseTheme = vi.fn() @@ -11,9 +11,9 @@ vi.mock('@/hooks/use-theme', () => ({ })) // Mock IconWithTooltip to directly test Partner's behavior -type IconWithTooltipProps = ComponentProps<typeof import('./icon-with-tooltip').default> +type IconWithTooltipProps = ComponentProps<typeof import('../icon-with-tooltip').default> const mockIconWithTooltip = vi.fn() -vi.mock('./icon-with-tooltip', () => ({ +vi.mock('../icon-with-tooltip', () => ({ default: (props: IconWithTooltipProps) => { mockIconWithTooltip(props) const { theme, BadgeIconLight, BadgeIconDark, className, popupContent } = props diff --git a/web/app/components/plugins/base/badges/__tests__/verified.spec.tsx b/web/app/components/plugins/base/badges/__tests__/verified.spec.tsx new file mode 100644 index 0000000000..809922a801 --- /dev/null +++ b/web/app/components/plugins/base/badges/__tests__/verified.spec.tsx @@ -0,0 +1,52 @@ +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() + }) +}) diff --git a/web/app/components/plugins/card/__tests__/card-more-info.spec.tsx b/web/app/components/plugins/card/__tests__/card-more-info.spec.tsx new file mode 100644 index 0000000000..769abf5f89 --- /dev/null +++ b/web/app/components/plugins/card/__tests__/card-more-info.spec.tsx @@ -0,0 +1,50 @@ +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() + }) +}) diff --git a/web/app/components/plugins/card/__tests__/index.spec.tsx b/web/app/components/plugins/card/__tests__/index.spec.tsx new file mode 100644 index 0000000000..aef89bd371 --- /dev/null +++ b/web/app/components/plugins/card/__tests__/index.spec.tsx @@ -0,0 +1,589 @@ +import type { Plugin } from '../../types' +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '../../types' +import Card from '../index' + +let mockTheme = 'light' +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: mockTheme }), +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (obj: Record<string, string>, locale: string) => { + return obj?.[locale] || obj?.['en-US'] || '' + }, +})) + +vi.mock('@/i18n-config/language', () => ({ + getLanguage: (locale: string) => locale || 'en-US', +})) + +const mockCategoriesMap: Record<string, { label: string }> = { + 'tool': { label: 'Tool' }, + 'model': { label: 'Model' }, + 'extension': { label: 'Extension' }, + 'agent-strategy': { label: 'Agent' }, + 'datasource': { label: 'Datasource' }, + 'trigger': { label: 'Trigger' }, + 'bundle': { label: 'Bundle' }, +} + +vi.mock('../../hooks', () => ({ + useCategories: () => ({ + categoriesMap: mockCategoriesMap, + }), +})) + +vi.mock('@/utils/format', () => ({ + formatNumber: (num: number) => num.toLocaleString(), +})) + +vi.mock('@/utils/mcp', () => ({ + shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗', +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ icon, background, innerIcon, size, iconType }: { + icon?: string + background?: string + innerIcon?: React.ReactNode + size?: string + iconType?: string + }) => ( + <div + data-testid="app-icon" + data-icon={icon} + data-background={background} + data-size={size} + data-icon-type={iconType} + > + {!!innerIcon && <div data-testid="inner-icon">{innerIcon}</div>} + </div> + ), +})) + +vi.mock('@/app/components/base/icons/src/vender/other', () => ({ + Mcp: ({ className }: { className?: string }) => ( + <div data-testid="mcp-icon" className={className}>MCP</div> + ), + Group: ({ className }: { className?: string }) => ( + <div data-testid="group-icon" className={className}>Group</div> + ), +})) + +vi.mock('../../../base/icons/src/vender/plugin', () => ({ + LeftCorner: ({ className }: { className?: string }) => ( + <div data-testid="left-corner" className={className}>LeftCorner</div> + ), +})) + +vi.mock('../../base/badges/partner', () => ({ + default: ({ className, text }: { className?: string, text?: string }) => ( + <div data-testid="partner-badge" className={className} title={text}>Partner</div> + ), +})) + +vi.mock('../../base/badges/verified', () => ({ + default: ({ className, text }: { className?: string, text?: string }) => ( + <div data-testid="verified-badge" className={className} title={text}>Verified</div> + ), +})) + +vi.mock('@/app/components/base/skeleton', () => ({ + SkeletonContainer: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="skeleton-container">{children}</div> + ), + SkeletonPoint: () => <div data-testid="skeleton-point" />, + SkeletonRectangle: ({ className }: { className?: string }) => ( + <div data-testid="skeleton-rectangle" className={className} /> + ), + SkeletonRow: ({ children, className }: { children: React.ReactNode, className?: string }) => ( + <div data-testid="skeleton-row" className={className}>{children}</div> + ), +})) + +const createMockPlugin = (overrides?: Partial<Plugin>): Plugin => ({ + type: 'plugin', + org: 'test-org', + name: 'test-plugin', + plugin_id: 'plugin-123', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'test-org/test-plugin:1.0.0', + icon: '/test-icon.png', + verified: false, + label: { 'en-US': 'Test Plugin' }, + brief: { 'en-US': 'Test plugin description' }, + description: { 'en-US': 'Full 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('Card', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render without crashing', () => { + const plugin = createMockPlugin() + render(<Card payload={plugin} />) + + expect(document.body).toBeInTheDocument() + }) + + it('should render plugin title from label', () => { + const plugin = createMockPlugin({ + label: { 'en-US': 'My Plugin Title' }, + }) + + render(<Card payload={plugin} />) + + expect(screen.getByText('My Plugin Title')).toBeInTheDocument() + }) + + it('should render plugin description from brief', () => { + const plugin = createMockPlugin({ + brief: { 'en-US': 'This is a brief description' }, + }) + + render(<Card payload={plugin} />) + + expect(screen.getByText('This is a brief description')).toBeInTheDocument() + }) + + it('should render organization info with org name and package name', () => { + const plugin = createMockPlugin({ + org: 'my-org', + name: 'my-plugin', + }) + + render(<Card payload={plugin} />) + + expect(screen.getByText('my-org')).toBeInTheDocument() + expect(screen.getByText('my-plugin')).toBeInTheDocument() + }) + + it('should render plugin icon', () => { + const plugin = createMockPlugin({ + icon: '/custom-icon.png', + }) + + const { container } = render(<Card payload={plugin} />) + + // Check for background image style on icon element + const iconElement = container.querySelector('[style*="background-image"]') + expect(iconElement).toBeInTheDocument() + }) + + it('should use icon_dark when theme is dark and icon_dark is provided', () => { + // Set theme to dark + mockTheme = 'dark' + + const plugin = createMockPlugin({ + icon: '/light-icon.png', + icon_dark: '/dark-icon.png', + }) + + const { container } = render(<Card payload={plugin} />) + + // Check that icon uses dark icon + const iconElement = container.querySelector('[style*="background-image"]') + expect(iconElement).toBeInTheDocument() + expect(iconElement).toHaveStyle({ backgroundImage: 'url(/dark-icon.png)' }) + + // Reset theme + mockTheme = 'light' + }) + + it('should use icon when theme is dark but icon_dark is not provided', () => { + mockTheme = 'dark' + + const plugin = createMockPlugin({ + icon: '/light-icon.png', + }) + + const { container } = render(<Card payload={plugin} />) + + // Should fallback to light icon + const iconElement = container.querySelector('[style*="background-image"]') + expect(iconElement).toBeInTheDocument() + expect(iconElement).toHaveStyle({ backgroundImage: 'url(/light-icon.png)' }) + + mockTheme = 'light' + }) + + it('should render corner mark with category label', () => { + const plugin = createMockPlugin({ + category: PluginCategoryEnum.tool, + }) + + render(<Card payload={plugin} />) + + expect(screen.getByText('Tool')).toBeInTheDocument() + }) + }) + + // ================================ + // Props Testing + // ================================ + describe('Props', () => { + it('should apply custom className', () => { + const plugin = createMockPlugin() + const { container } = render( + <Card payload={plugin} className="custom-class" />, + ) + + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) + + it('should hide corner mark when hideCornerMark is true', () => { + const plugin = createMockPlugin({ + category: PluginCategoryEnum.tool, + }) + + render(<Card payload={plugin} hideCornerMark={true} />) + + expect(screen.queryByTestId('left-corner')).not.toBeInTheDocument() + }) + + it('should show corner mark by default', () => { + const plugin = createMockPlugin() + + render(<Card payload={plugin} />) + + expect(screen.getByTestId('left-corner')).toBeInTheDocument() + }) + + it('should pass installed prop to Icon component', () => { + const plugin = createMockPlugin() + const { container } = render(<Card payload={plugin} installed={true} />) + + expect(container.querySelector('.bg-state-success-solid')).toBeInTheDocument() + }) + + it('should pass installFailed prop to Icon component', () => { + const plugin = createMockPlugin() + const { container } = render(<Card payload={plugin} installFailed={true} />) + + expect(container.querySelector('.bg-state-destructive-solid')).toBeInTheDocument() + }) + + it('should render footer when provided', () => { + const plugin = createMockPlugin() + render( + <Card payload={plugin} footer={<div data-testid="custom-footer">Footer Content</div>} />, + ) + + expect(screen.getByTestId('custom-footer')).toBeInTheDocument() + expect(screen.getByText('Footer Content')).toBeInTheDocument() + }) + + it('should render titleLeft when provided', () => { + const plugin = createMockPlugin() + render( + <Card payload={plugin} titleLeft={<span data-testid="title-left">v1.0</span>} />, + ) + + expect(screen.getByTestId('title-left')).toBeInTheDocument() + }) + + it('should use custom descriptionLineRows', () => { + const plugin = createMockPlugin() + + const { container } = render( + <Card payload={plugin} descriptionLineRows={1} />, + ) + + // Check for h-4 truncate class when descriptionLineRows is 1 + expect(container.querySelector('.h-4.truncate')).toBeInTheDocument() + }) + + it('should use default descriptionLineRows of 2', () => { + const plugin = createMockPlugin() + + const { container } = render(<Card payload={plugin} />) + + // Check for h-8 line-clamp-2 class when descriptionLineRows is 2 (default) + expect(container.querySelector('.h-8.line-clamp-2')).toBeInTheDocument() + }) + }) + + // ================================ + // Loading State Tests + // ================================ + describe('Loading State', () => { + it('should render Placeholder when isLoading is true', () => { + const plugin = createMockPlugin() + + render(<Card payload={plugin} isLoading={true} loadingFileName="loading.txt" />) + + // Should render skeleton elements + expect(screen.getByTestId('skeleton-container')).toBeInTheDocument() + }) + + it('should render loadingFileName in Placeholder', () => { + const plugin = createMockPlugin() + + render(<Card payload={plugin} isLoading={true} loadingFileName="my-plugin.zip" />) + + expect(screen.getByText('my-plugin.zip')).toBeInTheDocument() + }) + + it('should not render card content when loading', () => { + const plugin = createMockPlugin({ + label: { 'en-US': 'Plugin Title' }, + }) + + render(<Card payload={plugin} isLoading={true} loadingFileName="file.txt" />) + + // Plugin content should not be visible during loading + expect(screen.queryByText('Plugin Title')).not.toBeInTheDocument() + }) + + it('should not render loading state by default', () => { + const plugin = createMockPlugin() + + render(<Card payload={plugin} />) + + expect(screen.queryByTestId('skeleton-container')).not.toBeInTheDocument() + }) + }) + + // ================================ + // Badges Tests + // ================================ + describe('Badges', () => { + it('should render Partner badge when badges includes partner', () => { + const plugin = createMockPlugin({ + badges: ['partner'], + }) + + render(<Card payload={plugin} />) + + expect(screen.getByTestId('partner-badge')).toBeInTheDocument() + }) + + it('should render Verified badge when verified is true', () => { + const plugin = createMockPlugin({ + verified: true, + }) + + render(<Card payload={plugin} />) + + expect(screen.getByTestId('verified-badge')).toBeInTheDocument() + }) + + it('should render both Partner and Verified badges', () => { + const plugin = createMockPlugin({ + badges: ['partner'], + verified: true, + }) + + render(<Card payload={plugin} />) + + expect(screen.getByTestId('partner-badge')).toBeInTheDocument() + expect(screen.getByTestId('verified-badge')).toBeInTheDocument() + }) + + it('should not render Partner badge when badges is empty', () => { + const plugin = createMockPlugin({ + badges: [], + }) + + render(<Card payload={plugin} />) + + expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument() + }) + + it('should not render Verified badge when verified is false', () => { + const plugin = createMockPlugin({ + verified: false, + }) + + render(<Card payload={plugin} />) + + expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument() + }) + + it('should handle undefined badges gracefully', () => { + const plugin = createMockPlugin() + // @ts-expect-error - Testing undefined badges + plugin.badges = undefined + + render(<Card payload={plugin} />) + + expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument() + }) + }) + + // ================================ + // Limited Install Warning Tests + // ================================ + describe('Limited Install Warning', () => { + it('should render warning when limitedInstall is true', () => { + const plugin = createMockPlugin() + + const { container } = render(<Card payload={plugin} limitedInstall={true} />) + + expect(container.querySelector('.text-text-warning-secondary')).toBeInTheDocument() + }) + + it('should not render warning by default', () => { + const plugin = createMockPlugin() + + const { container } = render(<Card payload={plugin} />) + + expect(container.querySelector('.text-text-warning-secondary')).not.toBeInTheDocument() + }) + + it('should apply limited padding when limitedInstall is true', () => { + const plugin = createMockPlugin() + + const { container } = render(<Card payload={plugin} limitedInstall={true} />) + + expect(container.querySelector('.pb-1')).toBeInTheDocument() + }) + }) + + // ================================ + // Category Type Tests + // ================================ + describe('Category Types', () => { + it('should display bundle label for bundle type', () => { + const plugin = createMockPlugin({ + type: 'bundle', + category: PluginCategoryEnum.tool, + }) + + render(<Card payload={plugin} />) + + // For bundle type, should show 'Bundle' instead of category + expect(screen.getByText('Bundle')).toBeInTheDocument() + }) + + it('should display category label for non-bundle types', () => { + const plugin = createMockPlugin({ + type: 'plugin', + category: PluginCategoryEnum.model, + }) + + render(<Card payload={plugin} />) + + expect(screen.getByText('Model')).toBeInTheDocument() + }) + }) + + // ================================ + // Memoization Tests + // ================================ + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + // Card is wrapped with React.memo + expect(Card).toBeDefined() + // The component should have the memo display name characteristic + expect(typeof Card).toBe('object') + }) + + it('should not re-render when props are the same', () => { + const plugin = createMockPlugin() + const renderCount = vi.fn() + + const TestWrapper = ({ p }: { p: Plugin }) => { + renderCount() + return <Card payload={p} /> + } + + const { rerender } = render(<TestWrapper p={plugin} />) + expect(renderCount).toHaveBeenCalledTimes(1) + + // Re-render with same plugin reference + rerender(<TestWrapper p={plugin} />) + expect(renderCount).toHaveBeenCalledTimes(2) + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty label object', () => { + const plugin = createMockPlugin({ + label: {}, + }) + + render(<Card payload={plugin} />) + + // Should render without crashing + expect(document.body).toBeInTheDocument() + }) + + it('should handle empty brief object', () => { + const plugin = createMockPlugin({ + brief: {}, + }) + + render(<Card payload={plugin} />) + + expect(document.body).toBeInTheDocument() + }) + + it('should handle undefined label', () => { + const plugin = createMockPlugin() + // @ts-expect-error - Testing undefined label + plugin.label = undefined + + render(<Card payload={plugin} />) + + expect(document.body).toBeInTheDocument() + }) + + it('should handle special characters in plugin name', () => { + const plugin = createMockPlugin({ + name: 'plugin-with-special-chars!@#$%', + org: 'org<script>alert(1)</script>', + }) + + render(<Card payload={plugin} />) + + expect(screen.getByText('plugin-with-special-chars!@#$%')).toBeInTheDocument() + }) + + it('should handle very long title', () => { + const longTitle = 'A'.repeat(500) + const plugin = createMockPlugin({ + label: { 'en-US': longTitle }, + }) + + const { container } = render(<Card payload={plugin} />) + + // Should have truncate class for long text + expect(container.querySelector('.truncate')).toBeInTheDocument() + }) + + it('should handle very long description', () => { + const longDescription = 'B'.repeat(1000) + const plugin = createMockPlugin({ + brief: { 'en-US': longDescription }, + }) + + const { container } = render(<Card payload={plugin} />) + + // Should have line-clamp class for long text + expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx b/web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx new file mode 100644 index 0000000000..7eacd1c5ee --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/card-icon.spec.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Icon from '../card-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', () => { + const { container } = render(<Icon src="icon.png" installed />) + expect(container.querySelector('.bg-state-success-solid')).toBeInTheDocument() + }) + + it('shows close icon when installFailed', () => { + const { container } = render(<Icon src="icon.png" installFailed />) + expect(container.querySelector('.bg-state-destructive-solid')).toBeInTheDocument() + }) + + it('does not show status icons by default', () => { + const { container } = render(<Icon src="icon.png" />) + expect(container.querySelector('.bg-state-success-solid')).not.toBeInTheDocument() + expect(container.querySelector('.bg-state-destructive-solid')).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') + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx b/web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx new file mode 100644 index 0000000000..8c2e50dc44 --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/corner-mark.spec.tsx @@ -0,0 +1,27 @@ +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') + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/description.spec.tsx b/web/app/components/plugins/card/base/__tests__/description.spec.tsx new file mode 100644 index 0000000000..5008e8f63f --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/description.spec.tsx @@ -0,0 +1,37 @@ +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') + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/download-count.spec.tsx b/web/app/components/plugins/card/base/__tests__/download-count.spec.tsx new file mode 100644 index 0000000000..6bb52f8528 --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/download-count.spec.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import DownloadCount from '../download-count' + +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 zero download count', () => { + render(<DownloadCount downloadCount={0} />) + expect(screen.getByText('0')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/org-info.spec.tsx b/web/app/components/plugins/card/base/__tests__/org-info.spec.tsx new file mode 100644 index 0000000000..ac3461938f --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/org-info.spec.tsx @@ -0,0 +1,34 @@ +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') + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx b/web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx new file mode 100644 index 0000000000..076f4d69dd --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/placeholder.spec.tsx @@ -0,0 +1,71 @@ +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() + }) +}) diff --git a/web/app/components/plugins/card/base/__tests__/title.spec.tsx b/web/app/components/plugins/card/base/__tests__/title.spec.tsx new file mode 100644 index 0000000000..61c8936363 --- /dev/null +++ b/web/app/components/plugins/card/base/__tests__/title.spec.tsx @@ -0,0 +1,21 @@ +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() + }) +}) diff --git a/web/app/components/plugins/card/index.spec.tsx b/web/app/components/plugins/card/index.spec.tsx deleted file mode 100644 index 8406d6753d..0000000000 --- a/web/app/components/plugins/card/index.spec.tsx +++ /dev/null @@ -1,1877 +0,0 @@ -import type { Plugin } from '../types' -import { render, screen } from '@testing-library/react' -import * as React from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../types' - -import Icon from './base/card-icon' -import CornerMark from './base/corner-mark' -import Description from './base/description' -import DownloadCount from './base/download-count' -import OrgInfo from './base/org-info' -import Placeholder, { LoadingPlaceholder } from './base/placeholder' -import Title from './base/title' -import CardMoreInfo from './card-more-info' -// ================================ -// Import Components Under Test -// ================================ -import Card from './index' - -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock useTheme hook -let mockTheme = 'light' -vi.mock('@/hooks/use-theme', () => ({ - default: () => ({ theme: mockTheme }), -})) - -// Mock i18n-config -vi.mock('@/i18n-config', () => ({ - renderI18nObject: (obj: Record<string, string>, locale: string) => { - return obj?.[locale] || obj?.['en-US'] || '' - }, -})) - -// Mock i18n-config/language -vi.mock('@/i18n-config/language', () => ({ - getLanguage: (locale: string) => locale || 'en-US', -})) - -// Mock useCategories hook -const mockCategoriesMap: Record<string, { label: string }> = { - 'tool': { label: 'Tool' }, - 'model': { label: 'Model' }, - 'extension': { label: 'Extension' }, - 'agent-strategy': { label: 'Agent' }, - 'datasource': { label: 'Datasource' }, - 'trigger': { label: 'Trigger' }, - 'bundle': { label: 'Bundle' }, -} - -vi.mock('../hooks', () => ({ - useCategories: () => ({ - categoriesMap: mockCategoriesMap, - }), -})) - -// Mock formatNumber utility -vi.mock('@/utils/format', () => ({ - formatNumber: (num: number) => num.toLocaleString(), -})) - -// Mock shouldUseMcpIcon utility -vi.mock('@/utils/mcp', () => ({ - shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗', -})) - -// Mock AppIcon component -vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ icon, background, innerIcon, size, iconType }: { - icon?: string - background?: string - innerIcon?: React.ReactNode - size?: string - iconType?: string - }) => ( - <div - data-testid="app-icon" - data-icon={icon} - data-background={background} - data-size={size} - data-icon-type={iconType} - > - {!!innerIcon && <div data-testid="inner-icon">{innerIcon}</div>} - </div> - ), -})) - -// Mock Mcp icon component -vi.mock('@/app/components/base/icons/src/vender/other', () => ({ - Mcp: ({ className }: { className?: string }) => ( - <div data-testid="mcp-icon" className={className}>MCP</div> - ), - Group: ({ className }: { className?: string }) => ( - <div data-testid="group-icon" className={className}>Group</div> - ), -})) - -// Mock LeftCorner icon component -vi.mock('../../base/icons/src/vender/plugin', () => ({ - LeftCorner: ({ className }: { className?: string }) => ( - <div data-testid="left-corner" className={className}>LeftCorner</div> - ), -})) - -// Mock Partner badge -vi.mock('../base/badges/partner', () => ({ - default: ({ className, text }: { className?: string, text?: string }) => ( - <div data-testid="partner-badge" className={className} title={text}>Partner</div> - ), -})) - -// Mock Verified badge -vi.mock('../base/badges/verified', () => ({ - default: ({ className, text }: { className?: string, text?: string }) => ( - <div data-testid="verified-badge" className={className} title={text}>Verified</div> - ), -})) - -// Mock Skeleton components -vi.mock('@/app/components/base/skeleton', () => ({ - SkeletonContainer: ({ children }: { children: React.ReactNode }) => ( - <div data-testid="skeleton-container">{children}</div> - ), - SkeletonPoint: () => <div data-testid="skeleton-point" />, - SkeletonRectangle: ({ className }: { className?: string }) => ( - <div data-testid="skeleton-rectangle" className={className} /> - ), - SkeletonRow: ({ children, className }: { children: React.ReactNode, className?: string }) => ( - <div data-testid="skeleton-row" className={className}>{children}</div> - ), -})) - -// Mock Remix icons -vi.mock('@remixicon/react', () => ({ - RiCheckLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-check-line" className={className}>✓</span> - ), - RiCloseLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-close-line" className={className}>✕</span> - ), - RiInstallLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-install-line" className={className}>↓</span> - ), - RiAlertFill: ({ className }: { className?: string }) => ( - <span data-testid="ri-alert-fill" className={className}>⚠</span> - ), -})) - -// ================================ -// Test Data Factories -// ================================ - -const createMockPlugin = (overrides?: Partial<Plugin>): Plugin => ({ - type: 'plugin', - org: 'test-org', - name: 'test-plugin', - plugin_id: 'plugin-123', - version: '1.0.0', - latest_version: '1.0.0', - latest_package_identifier: 'test-org/test-plugin:1.0.0', - icon: '/test-icon.png', - verified: false, - label: { 'en-US': 'Test Plugin' }, - brief: { 'en-US': 'Test plugin description' }, - description: { 'en-US': 'Full 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, -}) - -// ================================ -// Card Component Tests (index.tsx) -// ================================ -describe('Card', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - const plugin = createMockPlugin() - render(<Card payload={plugin} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render plugin title from label', () => { - const plugin = createMockPlugin({ - label: { 'en-US': 'My Plugin Title' }, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('My Plugin Title')).toBeInTheDocument() - }) - - it('should render plugin description from brief', () => { - const plugin = createMockPlugin({ - brief: { 'en-US': 'This is a brief description' }, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('This is a brief description')).toBeInTheDocument() - }) - - it('should render organization info with org name and package name', () => { - const plugin = createMockPlugin({ - org: 'my-org', - name: 'my-plugin', - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('my-org')).toBeInTheDocument() - expect(screen.getByText('my-plugin')).toBeInTheDocument() - }) - - it('should render plugin icon', () => { - const plugin = createMockPlugin({ - icon: '/custom-icon.png', - }) - - const { container } = render(<Card payload={plugin} />) - - // Check for background image style on icon element - const iconElement = container.querySelector('[style*="background-image"]') - expect(iconElement).toBeInTheDocument() - }) - - it('should use icon_dark when theme is dark and icon_dark is provided', () => { - // Set theme to dark - mockTheme = 'dark' - - const plugin = createMockPlugin({ - icon: '/light-icon.png', - icon_dark: '/dark-icon.png', - }) - - const { container } = render(<Card payload={plugin} />) - - // Check that icon uses dark icon - const iconElement = container.querySelector('[style*="background-image"]') - expect(iconElement).toBeInTheDocument() - expect(iconElement).toHaveStyle({ backgroundImage: 'url(/dark-icon.png)' }) - - // Reset theme - mockTheme = 'light' - }) - - it('should use icon when theme is dark but icon_dark is not provided', () => { - mockTheme = 'dark' - - const plugin = createMockPlugin({ - icon: '/light-icon.png', - }) - - const { container } = render(<Card payload={plugin} />) - - // Should fallback to light icon - const iconElement = container.querySelector('[style*="background-image"]') - expect(iconElement).toBeInTheDocument() - expect(iconElement).toHaveStyle({ backgroundImage: 'url(/light-icon.png)' }) - - mockTheme = 'light' - }) - - it('should render corner mark with category label', () => { - const plugin = createMockPlugin({ - category: PluginCategoryEnum.tool, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Tool')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should apply custom className', () => { - const plugin = createMockPlugin() - const { container } = render( - <Card payload={plugin} className="custom-class" />, - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - - it('should hide corner mark when hideCornerMark is true', () => { - const plugin = createMockPlugin({ - category: PluginCategoryEnum.tool, - }) - - render(<Card payload={plugin} hideCornerMark={true} />) - - expect(screen.queryByTestId('left-corner')).not.toBeInTheDocument() - }) - - it('should show corner mark by default', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} />) - - expect(screen.getByTestId('left-corner')).toBeInTheDocument() - }) - - it('should pass installed prop to Icon component', () => { - const plugin = createMockPlugin() - render(<Card payload={plugin} installed={true} />) - - // Check for the check icon that appears when installed - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() - }) - - it('should pass installFailed prop to Icon component', () => { - const plugin = createMockPlugin() - render(<Card payload={plugin} installFailed={true} />) - - // Check for the close icon that appears when install failed - expect(screen.getByTestId('ri-close-line')).toBeInTheDocument() - }) - - it('should render footer when provided', () => { - const plugin = createMockPlugin() - render( - <Card payload={plugin} footer={<div data-testid="custom-footer">Footer Content</div>} />, - ) - - expect(screen.getByTestId('custom-footer')).toBeInTheDocument() - expect(screen.getByText('Footer Content')).toBeInTheDocument() - }) - - it('should render titleLeft when provided', () => { - const plugin = createMockPlugin() - render( - <Card payload={plugin} titleLeft={<span data-testid="title-left">v1.0</span>} />, - ) - - expect(screen.getByTestId('title-left')).toBeInTheDocument() - }) - - it('should use custom descriptionLineRows', () => { - const plugin = createMockPlugin() - - const { container } = render( - <Card payload={plugin} descriptionLineRows={1} />, - ) - - // Check for h-4 truncate class when descriptionLineRows is 1 - expect(container.querySelector('.h-4.truncate')).toBeInTheDocument() - }) - - it('should use default descriptionLineRows of 2', () => { - const plugin = createMockPlugin() - - const { container } = render(<Card payload={plugin} />) - - // Check for h-8 line-clamp-2 class when descriptionLineRows is 2 (default) - expect(container.querySelector('.h-8.line-clamp-2')).toBeInTheDocument() - }) - }) - - // ================================ - // Loading State Tests - // ================================ - describe('Loading State', () => { - it('should render Placeholder when isLoading is true', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} isLoading={true} loadingFileName="loading.txt" />) - - // Should render skeleton elements - expect(screen.getByTestId('skeleton-container')).toBeInTheDocument() - }) - - it('should render loadingFileName in Placeholder', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} isLoading={true} loadingFileName="my-plugin.zip" />) - - expect(screen.getByText('my-plugin.zip')).toBeInTheDocument() - }) - - it('should not render card content when loading', () => { - const plugin = createMockPlugin({ - label: { 'en-US': 'Plugin Title' }, - }) - - render(<Card payload={plugin} isLoading={true} loadingFileName="file.txt" />) - - // Plugin content should not be visible during loading - expect(screen.queryByText('Plugin Title')).not.toBeInTheDocument() - }) - - it('should not render loading state by default', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} />) - - expect(screen.queryByTestId('skeleton-container')).not.toBeInTheDocument() - }) - }) - - // ================================ - // Badges Tests - // ================================ - describe('Badges', () => { - it('should render Partner badge when badges includes partner', () => { - const plugin = createMockPlugin({ - badges: ['partner'], - }) - - render(<Card payload={plugin} />) - - expect(screen.getByTestId('partner-badge')).toBeInTheDocument() - }) - - it('should render Verified badge when verified is true', () => { - const plugin = createMockPlugin({ - verified: true, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByTestId('verified-badge')).toBeInTheDocument() - }) - - it('should render both Partner and Verified badges', () => { - const plugin = createMockPlugin({ - badges: ['partner'], - verified: true, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByTestId('partner-badge')).toBeInTheDocument() - expect(screen.getByTestId('verified-badge')).toBeInTheDocument() - }) - - it('should not render Partner badge when badges is empty', () => { - const plugin = createMockPlugin({ - badges: [], - }) - - render(<Card payload={plugin} />) - - expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument() - }) - - it('should not render Verified badge when verified is false', () => { - const plugin = createMockPlugin({ - verified: false, - }) - - render(<Card payload={plugin} />) - - expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument() - }) - - it('should handle undefined badges gracefully', () => { - const plugin = createMockPlugin() - // @ts-expect-error - Testing undefined badges - plugin.badges = undefined - - render(<Card payload={plugin} />) - - expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument() - }) - }) - - // ================================ - // Limited Install Warning Tests - // ================================ - describe('Limited Install Warning', () => { - it('should render warning when limitedInstall is true', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} limitedInstall={true} />) - - expect(screen.getByTestId('ri-alert-fill')).toBeInTheDocument() - }) - - it('should not render warning by default', () => { - const plugin = createMockPlugin() - - render(<Card payload={plugin} />) - - expect(screen.queryByTestId('ri-alert-fill')).not.toBeInTheDocument() - }) - - it('should apply limited padding when limitedInstall is true', () => { - const plugin = createMockPlugin() - - const { container } = render(<Card payload={plugin} limitedInstall={true} />) - - expect(container.querySelector('.pb-1')).toBeInTheDocument() - }) - }) - - // ================================ - // Category Type Tests - // ================================ - describe('Category Types', () => { - it('should display bundle label for bundle type', () => { - const plugin = createMockPlugin({ - type: 'bundle', - category: PluginCategoryEnum.tool, - }) - - render(<Card payload={plugin} />) - - // For bundle type, should show 'Bundle' instead of category - expect(screen.getByText('Bundle')).toBeInTheDocument() - }) - - it('should display category label for non-bundle types', () => { - const plugin = createMockPlugin({ - type: 'plugin', - category: PluginCategoryEnum.model, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Model')).toBeInTheDocument() - }) - }) - - // ================================ - // Memoization Tests - // ================================ - describe('Memoization', () => { - it('should be memoized with React.memo', () => { - // Card is wrapped with React.memo - expect(Card).toBeDefined() - // The component should have the memo display name characteristic - expect(typeof Card).toBe('object') - }) - - it('should not re-render when props are the same', () => { - const plugin = createMockPlugin() - const renderCount = vi.fn() - - const TestWrapper = ({ p }: { p: Plugin }) => { - renderCount() - return <Card payload={p} /> - } - - const { rerender } = render(<TestWrapper p={plugin} />) - expect(renderCount).toHaveBeenCalledTimes(1) - - // Re-render with same plugin reference - rerender(<TestWrapper p={plugin} />) - expect(renderCount).toHaveBeenCalledTimes(2) - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty label object', () => { - const plugin = createMockPlugin({ - label: {}, - }) - - render(<Card payload={plugin} />) - - // Should render without crashing - expect(document.body).toBeInTheDocument() - }) - - it('should handle empty brief object', () => { - const plugin = createMockPlugin({ - brief: {}, - }) - - render(<Card payload={plugin} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should handle undefined label', () => { - const plugin = createMockPlugin() - // @ts-expect-error - Testing undefined label - plugin.label = undefined - - render(<Card payload={plugin} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should handle special characters in plugin name', () => { - const plugin = createMockPlugin({ - name: 'plugin-with-special-chars!@#$%', - org: 'org<script>alert(1)</script>', - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('plugin-with-special-chars!@#$%')).toBeInTheDocument() - }) - - it('should handle very long title', () => { - const longTitle = 'A'.repeat(500) - const plugin = createMockPlugin({ - label: { 'en-US': longTitle }, - }) - - const { container } = render(<Card payload={plugin} />) - - // Should have truncate class for long text - expect(container.querySelector('.truncate')).toBeInTheDocument() - }) - - it('should handle very long description', () => { - const longDescription = 'B'.repeat(1000) - const plugin = createMockPlugin({ - brief: { 'en-US': longDescription }, - }) - - const { container } = render(<Card payload={plugin} />) - - // Should have line-clamp class for long text - expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// CardMoreInfo Component Tests -// ================================ -describe('CardMoreInfo', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<CardMoreInfo downloadCount={100} tags={['tag1']} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render download count when provided', () => { - render(<CardMoreInfo downloadCount={1000} tags={[]} />) - - expect(screen.getByText('1,000')).toBeInTheDocument() - }) - - it('should render tags when provided', () => { - render(<CardMoreInfo tags={['search', 'image']} />) - - expect(screen.getByText('search')).toBeInTheDocument() - expect(screen.getByText('image')).toBeInTheDocument() - }) - - it('should render both download count and tags with separator', () => { - render(<CardMoreInfo downloadCount={500} tags={['tag1']} />) - - expect(screen.getByText('500')).toBeInTheDocument() - expect(screen.getByText('·')).toBeInTheDocument() - expect(screen.getByText('tag1')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should not render download count when undefined', () => { - render(<CardMoreInfo tags={['tag1']} />) - - expect(screen.queryByTestId('ri-install-line')).not.toBeInTheDocument() - }) - - it('should not render separator when download count is undefined', () => { - render(<CardMoreInfo tags={['tag1']} />) - - expect(screen.queryByText('·')).not.toBeInTheDocument() - }) - - it('should not render separator when tags are empty', () => { - render(<CardMoreInfo downloadCount={100} tags={[]} />) - - expect(screen.queryByText('·')).not.toBeInTheDocument() - }) - - it('should render hash symbol before each tag', () => { - render(<CardMoreInfo tags={['search']} />) - - expect(screen.getByText('#')).toBeInTheDocument() - }) - - it('should set title attribute with hash prefix for tags', () => { - render(<CardMoreInfo tags={['search']} />) - - const tagElement = screen.getByTitle('# search') - expect(tagElement).toBeInTheDocument() - }) - }) - - // ================================ - // Memoization Tests - // ================================ - describe('Memoization', () => { - it('should be memoized with React.memo', () => { - expect(CardMoreInfo).toBeDefined() - expect(typeof CardMoreInfo).toBe('object') - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle zero download count', () => { - render(<CardMoreInfo downloadCount={0} tags={[]} />) - - // 0 should still render since downloadCount is defined - expect(screen.getByText('0')).toBeInTheDocument() - }) - - it('should handle empty tags array', () => { - render(<CardMoreInfo downloadCount={100} tags={[]} />) - - expect(screen.queryByText('#')).not.toBeInTheDocument() - }) - - it('should handle large download count', () => { - render(<CardMoreInfo downloadCount={1234567890} tags={[]} />) - - expect(screen.getByText('1,234,567,890')).toBeInTheDocument() - }) - - it('should handle many tags', () => { - const tags = Array.from({ length: 10 }, (_, i) => `tag${i}`) - render(<CardMoreInfo downloadCount={100} tags={tags} />) - - expect(screen.getByText('tag0')).toBeInTheDocument() - expect(screen.getByText('tag9')).toBeInTheDocument() - }) - - it('should handle tags with special characters', () => { - render(<CardMoreInfo tags={['tag-with-dash', 'tag_with_underscore']} />) - - expect(screen.getByText('tag-with-dash')).toBeInTheDocument() - expect(screen.getByText('tag_with_underscore')).toBeInTheDocument() - }) - - it('should truncate long tag names', () => { - const longTag = 'a'.repeat(200) - const { container } = render(<CardMoreInfo tags={[longTag]} />) - - expect(container.querySelector('.truncate')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// Icon Component Tests (base/card-icon.tsx) -// ================================ -describe('Icon', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing with string src', () => { - render(<Icon src="/icon.png" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render without crashing with object src', () => { - render(<Icon src={{ content: '🎉', background: '#fff' }} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render background image for string src', () => { - const { container } = render(<Icon src="/test-icon.png" />) - - const iconDiv = container.firstChild as HTMLElement - expect(iconDiv).toHaveStyle({ backgroundImage: 'url(/test-icon.png)' }) - }) - - it('should render AppIcon for object src', () => { - render(<Icon src={{ content: '🎉', background: '#ffffff' }} />) - - expect(screen.getByTestId('app-icon')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render(<Icon src="/icon.png" className="custom-icon-class" />) - - expect(container.querySelector('.custom-icon-class')).toBeInTheDocument() - }) - - it('should render check icon when installed is true', () => { - render(<Icon src="/icon.png" installed={true} />) - - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() - }) - - it('should render close icon when installFailed is true', () => { - render(<Icon src="/icon.png" installFailed={true} />) - - expect(screen.getByTestId('ri-close-line')).toBeInTheDocument() - }) - - it('should not render status icon when neither installed nor failed', () => { - render(<Icon src="/icon.png" />) - - expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() - expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument() - }) - - it('should use default size of large', () => { - const { container } = render(<Icon src="/icon.png" />) - - expect(container.querySelector('.w-10.h-10')).toBeInTheDocument() - }) - - it('should apply xs size class', () => { - const { container } = render(<Icon src="/icon.png" size="xs" />) - - expect(container.querySelector('.w-4.h-4')).toBeInTheDocument() - }) - - it('should apply tiny size class', () => { - const { container } = render(<Icon src="/icon.png" size="tiny" />) - - expect(container.querySelector('.w-6.h-6')).toBeInTheDocument() - }) - - it('should apply small size class', () => { - const { container } = render(<Icon src="/icon.png" size="small" />) - - expect(container.querySelector('.w-8.h-8')).toBeInTheDocument() - }) - - it('should apply medium size class', () => { - const { container } = render(<Icon src="/icon.png" size="medium" />) - - expect(container.querySelector('.w-9.h-9')).toBeInTheDocument() - }) - - it('should apply large size class', () => { - const { container } = render(<Icon src="/icon.png" size="large" />) - - expect(container.querySelector('.w-10.h-10')).toBeInTheDocument() - }) - }) - - // ================================ - // MCP Icon Tests - // ================================ - describe('MCP Icon', () => { - it('should render MCP icon when src content is 🔗', () => { - render(<Icon src={{ content: '🔗', background: '#ffffff' }} />) - - expect(screen.getByTestId('mcp-icon')).toBeInTheDocument() - }) - - it('should not render MCP icon for other emoji content', () => { - render(<Icon src={{ content: '🎉', background: '#ffffff' }} />) - - expect(screen.queryByTestId('mcp-icon')).not.toBeInTheDocument() - }) - }) - - // ================================ - // Status Indicator Tests - // ================================ - describe('Status Indicators', () => { - it('should render success indicator with correct styling for installed', () => { - const { container } = render(<Icon src="/icon.png" installed={true} />) - - expect(container.querySelector('.bg-state-success-solid')).toBeInTheDocument() - }) - - it('should render destructive indicator with correct styling for failed', () => { - const { container } = render(<Icon src="/icon.png" installFailed={true} />) - - expect(container.querySelector('.bg-state-destructive-solid')).toBeInTheDocument() - }) - - it('should prioritize installed over installFailed', () => { - // When both are true, installed takes precedence (rendered first in code) - render(<Icon src="/icon.png" installed={true} installFailed={true} />) - - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() - }) - }) - - // ================================ - // Object src Tests - // ================================ - describe('Object src', () => { - it('should render AppIcon with correct icon prop', () => { - render(<Icon src={{ content: '🎉', background: '#ffffff' }} />) - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-icon', '🎉') - }) - - it('should render AppIcon with correct background prop', () => { - render(<Icon src={{ content: 'đŸ”„', background: '#ff0000' }} />) - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-background', '#ff0000') - }) - - it('should render AppIcon with emoji iconType', () => { - render(<Icon src={{ content: '⭐', background: '#ffff00' }} />) - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-icon-type', 'emoji') - }) - - it('should render AppIcon with correct size', () => { - render(<Icon src={{ content: '📩', background: '#0000ff' }} size="small" />) - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-size', 'small') - }) - - it('should apply className to wrapper div for object src', () => { - const { container } = render( - <Icon src={{ content: '🎹', background: '#00ff00' }} className="custom-class" />, - ) - - expect(container.querySelector('.relative.custom-class')).toBeInTheDocument() - }) - - it('should render with all size options for object src', () => { - const sizes = ['xs', 'tiny', 'small', 'medium', 'large'] as const - sizes.forEach((size) => { - const { unmount } = render( - <Icon src={{ content: 'đŸ“±', background: '#ffffff' }} size={size} />, - ) - expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size) - unmount() - }) - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty string src', () => { - const { container } = render(<Icon src="" />) - - expect(container.firstChild).toBeInTheDocument() - }) - - it('should handle special characters in URL', () => { - const { container } = render(<Icon src="/icon?name=test&size=large" />) - - const iconDiv = container.firstChild as HTMLElement - expect(iconDiv).toHaveStyle({ backgroundImage: 'url(/icon?name=test&size=large)' }) - }) - - it('should handle object src with special emoji', () => { - render(<Icon src={{ content: 'đŸ‘šâ€đŸ’»', background: '#123456' }} />) - - expect(screen.getByTestId('app-icon')).toBeInTheDocument() - }) - - it('should handle object src with empty content', () => { - render(<Icon src={{ content: '', background: '#ffffff' }} />) - - expect(screen.getByTestId('app-icon')).toBeInTheDocument() - }) - - it('should not render status indicators when src is object with installed=true', () => { - render(<Icon src={{ content: '🎉', background: '#fff' }} installed={true} />) - - // Status indicators should not render for object src - expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() - }) - - it('should not render status indicators when src is object with installFailed=true', () => { - render(<Icon src={{ content: '🎉', background: '#fff' }} installFailed={true} />) - - // Status indicators should not render for object src - expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument() - }) - - it('should render object src with all size variants', () => { - const sizes: Array<'xs' | 'tiny' | 'small' | 'medium' | 'large'> = ['xs', 'tiny', 'small', 'medium', 'large'] - - sizes.forEach((size) => { - const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} size={size} />) - expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size) - unmount() - }) - }) - - it('should render object src with custom className', () => { - const { container } = render( - <Icon src={{ content: '🎉', background: '#fff' }} className="custom-object-icon" />, - ) - - expect(container.querySelector('.custom-object-icon')).toBeInTheDocument() - }) - - it('should pass correct props to AppIcon for object src', () => { - render(<Icon src={{ content: '😀', background: '#123456' }} />) - - const appIcon = screen.getByTestId('app-icon') - expect(appIcon).toHaveAttribute('data-icon', '😀') - expect(appIcon).toHaveAttribute('data-background', '#123456') - expect(appIcon).toHaveAttribute('data-icon-type', 'emoji') - }) - - it('should render inner icon only when shouldUseMcpIcon returns true', () => { - // Test with MCP icon content - const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} />) - expect(screen.getByTestId('inner-icon')).toBeInTheDocument() - unmount() - - // Test without MCP icon content - render(<Icon src={{ content: '🎉', background: '#fff' }} />) - expect(screen.queryByTestId('inner-icon')).not.toBeInTheDocument() - }) - }) - - // ================================ - // CornerMark Component Tests - // ================================ - describe('CornerMark', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<CornerMark text="Tool" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render text content', () => { - render(<CornerMark text="Tool" />) - - expect(screen.getByText('Tool')).toBeInTheDocument() - }) - - it('should render LeftCorner icon', () => { - render(<CornerMark text="Model" />) - - expect(screen.getByTestId('left-corner')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should display different category text', () => { - const { rerender } = render(<CornerMark text="Tool" />) - expect(screen.getByText('Tool')).toBeInTheDocument() - - rerender(<CornerMark text="Model" />) - expect(screen.getByText('Model')).toBeInTheDocument() - - rerender(<CornerMark text="Extension" />) - expect(screen.getByText('Extension')).toBeInTheDocument() - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty text', () => { - render(<CornerMark text="" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should handle long text', () => { - const longText = 'Very Long Category Name' - render(<CornerMark text={longText} />) - - expect(screen.getByText(longText)).toBeInTheDocument() - }) - - it('should handle special characters in text', () => { - render(<CornerMark text="Test & Demo" />) - - expect(screen.getByText('Test & Demo')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // Description Component Tests - // ================================ - describe('Description', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<Description text="Test description" descriptionLineRows={2} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render text content', () => { - render(<Description text="This is a description" descriptionLineRows={2} />) - - expect(screen.getByText('This is a description')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={2} className="custom-desc-class" />, - ) - - expect(container.querySelector('.custom-desc-class')).toBeInTheDocument() - }) - - it('should apply h-4 truncate for 1 line row', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={1} />, - ) - - expect(container.querySelector('.h-4.truncate')).toBeInTheDocument() - }) - - it('should apply h-8 line-clamp-2 for 2 line rows', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={2} />, - ) - - expect(container.querySelector('.h-8.line-clamp-2')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for 3+ line rows', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={3} />, - ) - - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for values greater than 3', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={5} />, - ) - - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for descriptionLineRows of 4', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={4} />, - ) - - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for descriptionLineRows of 10', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={10} />, - ) - - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for descriptionLineRows of 0', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={0} />, - ) - - // 0 is neither 1 nor 2, so it should use the else branch - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - - it('should apply h-12 line-clamp-3 for negative descriptionLineRows', () => { - const { container } = render( - <Description text="Test" descriptionLineRows={-1} />, - ) - - // negative is neither 1 nor 2, so it should use the else branch - expect(container.querySelector('.h-12.line-clamp-3')).toBeInTheDocument() - }) - }) - - // ================================ - // Memoization Tests - // ================================ - describe('Memoization', () => { - it('should memoize lineClassName based on descriptionLineRows', () => { - const { container, rerender } = render( - <Description text="Test" descriptionLineRows={2} />, - ) - - expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() - - // Re-render with same descriptionLineRows - rerender(<Description text="Different text" descriptionLineRows={2} />) - - // Should still have same class (memoized) - expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty text', () => { - render(<Description text="" descriptionLineRows={2} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should handle very long text', () => { - const longText = 'A'.repeat(1000) - const { container } = render( - <Description text={longText} descriptionLineRows={2} />, - ) - - expect(container.querySelector('.line-clamp-2')).toBeInTheDocument() - }) - - it('should handle text with HTML entities', () => { - render(<Description text="<script>alert('xss')</script>" descriptionLineRows={2} />) - - // Text should be escaped - expect(screen.getByText('<script>alert(\'xss\')</script>')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // DownloadCount Component Tests - // ================================ - describe('DownloadCount', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<DownloadCount downloadCount={100} />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render download count with formatted number', () => { - render(<DownloadCount downloadCount={1234567} />) - - expect(screen.getByText('1,234,567')).toBeInTheDocument() - }) - - it('should render install icon', () => { - render(<DownloadCount downloadCount={100} />) - - expect(screen.getByTestId('ri-install-line')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should display small download count', () => { - render(<DownloadCount downloadCount={5} />) - - expect(screen.getByText('5')).toBeInTheDocument() - }) - - it('should display large download count', () => { - render(<DownloadCount downloadCount={999999999} />) - - expect(screen.getByText('999,999,999')).toBeInTheDocument() - }) - }) - - // ================================ - // Memoization Tests - // ================================ - describe('Memoization', () => { - it('should be memoized with React.memo', () => { - expect(DownloadCount).toBeDefined() - expect(typeof DownloadCount).toBe('object') - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle zero download count', () => { - render(<DownloadCount downloadCount={0} />) - - // 0 should still render with install icon - expect(screen.getByText('0')).toBeInTheDocument() - expect(screen.getByTestId('ri-install-line')).toBeInTheDocument() - }) - - it('should handle negative download count', () => { - render(<DownloadCount downloadCount={-100} />) - - expect(screen.getByText('-100')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // OrgInfo Component Tests - // ================================ - describe('OrgInfo', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<OrgInfo packageName="test-plugin" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render package name', () => { - render(<OrgInfo packageName="my-plugin" />) - - expect(screen.getByText('my-plugin')).toBeInTheDocument() - }) - - it('should render org name and separator when provided', () => { - render(<OrgInfo orgName="my-org" packageName="my-plugin" />) - - expect(screen.getByText('my-org')).toBeInTheDocument() - expect(screen.getByText('/')).toBeInTheDocument() - expect(screen.getByText('my-plugin')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render( - <OrgInfo packageName="test" className="custom-org-class" />, - ) - - expect(container.querySelector('.custom-org-class')).toBeInTheDocument() - }) - - it('should apply packageNameClassName', () => { - const { container } = render( - <OrgInfo packageName="test" packageNameClassName="custom-package-class" />, - ) - - expect(container.querySelector('.custom-package-class')).toBeInTheDocument() - }) - - it('should not render org name section when orgName is undefined', () => { - render(<OrgInfo packageName="test" />) - - expect(screen.queryByText('/')).not.toBeInTheDocument() - }) - - it('should not render org name section when orgName is empty', () => { - render(<OrgInfo orgName="" packageName="test" />) - - expect(screen.queryByText('/')).not.toBeInTheDocument() - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle special characters in org name', () => { - render(<OrgInfo orgName="my-org_123" packageName="test" />) - - expect(screen.getByText('my-org_123')).toBeInTheDocument() - }) - - it('should handle special characters in package name', () => { - render(<OrgInfo packageName="plugin@v1.0.0" />) - - expect(screen.getByText('plugin@v1.0.0')).toBeInTheDocument() - }) - - it('should truncate long package name', () => { - const longName = 'a'.repeat(100) - const { container } = render(<OrgInfo packageName={longName} />) - - expect(container.querySelector('.truncate')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // Placeholder Component Tests - // ================================ - describe('Placeholder', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<Placeholder wrapClassName="test-class" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render with wrapClassName', () => { - const { container } = render( - <Placeholder wrapClassName="custom-wrapper" />, - ) - - expect(container.querySelector('.custom-wrapper')).toBeInTheDocument() - }) - - it('should render skeleton elements', () => { - render(<Placeholder wrapClassName="test" />) - - expect(screen.getByTestId('skeleton-container')).toBeInTheDocument() - expect(screen.getAllByTestId('skeleton-rectangle').length).toBeGreaterThan(0) - }) - - it('should render Group icon', () => { - render(<Placeholder wrapClassName="test" />) - - expect(screen.getByTestId('group-icon')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should render Title when loadingFileName is provided', () => { - render(<Placeholder wrapClassName="test" loadingFileName="my-file.zip" />) - - expect(screen.getByText('my-file.zip')).toBeInTheDocument() - }) - - it('should render SkeletonRectangle when loadingFileName is not provided', () => { - render(<Placeholder wrapClassName="test" />) - - // Should have skeleton rectangle for title area - const rectangles = screen.getAllByTestId('skeleton-rectangle') - expect(rectangles.length).toBeGreaterThan(0) - }) - - it('should render SkeletonRow for org info', () => { - render(<Placeholder wrapClassName="test" />) - - // There are multiple skeleton rows in the component - const skeletonRows = screen.getAllByTestId('skeleton-row') - expect(skeletonRows.length).toBeGreaterThan(0) - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty wrapClassName', () => { - const { container } = render(<Placeholder wrapClassName="" />) - - expect(container.firstChild).toBeInTheDocument() - }) - - it('should handle undefined loadingFileName', () => { - render(<Placeholder wrapClassName="test" loadingFileName={undefined} />) - - // Should show skeleton instead of title - const rectangles = screen.getAllByTestId('skeleton-rectangle') - expect(rectangles.length).toBeGreaterThan(0) - }) - - it('should handle long loadingFileName', () => { - const longFileName = 'very-long-file-name-that-goes-on-forever.zip' - render(<Placeholder wrapClassName="test" loadingFileName={longFileName} />) - - expect(screen.getByText(longFileName)).toBeInTheDocument() - }) - }) - }) - - // ================================ - // LoadingPlaceholder Component Tests - // ================================ - describe('LoadingPlaceholder', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<LoadingPlaceholder />) - - expect(document.body).toBeInTheDocument() - }) - - it('should have correct base classes', () => { - const { container } = render(<LoadingPlaceholder />) - - expect(container.querySelector('.h-2.rounded-sm')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render(<LoadingPlaceholder className="custom-loading" />) - - expect(container.querySelector('.custom-loading')).toBeInTheDocument() - }) - - it('should merge className with base classes', () => { - const { container } = render(<LoadingPlaceholder className="w-full" />) - - expect(container.querySelector('.h-2.rounded-sm.w-full')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // Title Component Tests - // ================================ - describe('Title', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ================================ - // Rendering Tests - // ================================ - describe('Rendering', () => { - it('should render without crashing', () => { - render(<Title title="Test Title" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should render title text', () => { - render(<Title title="My Plugin Title" />) - - expect(screen.getByText('My Plugin Title')).toBeInTheDocument() - }) - - it('should have truncate class', () => { - const { container } = render(<Title title="Test" />) - - expect(container.querySelector('.truncate')).toBeInTheDocument() - }) - - it('should have correct text styling', () => { - const { container } = render(<Title title="Test" />) - - expect(container.querySelector('.system-md-semibold')).toBeInTheDocument() - expect(container.querySelector('.text-text-secondary')).toBeInTheDocument() - }) - }) - - // ================================ - // Props Testing - // ================================ - describe('Props', () => { - it('should display different titles', () => { - const { rerender } = render(<Title title="First Title" />) - expect(screen.getByText('First Title')).toBeInTheDocument() - - rerender(<Title title="Second Title" />) - expect(screen.getByText('Second Title')).toBeInTheDocument() - }) - }) - - // ================================ - // Edge Cases Tests - // ================================ - describe('Edge Cases', () => { - it('should handle empty title', () => { - render(<Title title="" />) - - expect(document.body).toBeInTheDocument() - }) - - it('should handle very long title', () => { - const longTitle = 'A'.repeat(500) - const { container } = render(<Title title={longTitle} />) - - // Should have truncate for long text - expect(container.querySelector('.truncate')).toBeInTheDocument() - }) - - it('should handle special characters in title', () => { - render(<Title title={'Title with <special> & "chars"'} />) - - expect(screen.getByText('Title with <special> & "chars"')).toBeInTheDocument() - }) - - it('should handle unicode characters', () => { - render(<Title title="æ ‡éą˜ 🎉 ă‚żă‚€ăƒˆăƒ«" />) - - expect(screen.getByText('æ ‡éą˜ 🎉 ă‚żă‚€ăƒˆăƒ«')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // Integration Tests - // ================================ - describe('Card Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Complete Card Rendering', () => { - it('should render a complete card with all elements', () => { - const plugin = createMockPlugin({ - label: { 'en-US': 'Complete Plugin' }, - brief: { 'en-US': 'A complete plugin description' }, - org: 'complete-org', - name: 'complete-plugin', - category: PluginCategoryEnum.tool, - verified: true, - badges: ['partner'], - }) - - render( - <Card - payload={plugin} - footer={<CardMoreInfo downloadCount={5000} tags={['search', 'api']} />} - />, - ) - - // Verify all elements are rendered - expect(screen.getByText('Complete Plugin')).toBeInTheDocument() - expect(screen.getByText('A complete plugin description')).toBeInTheDocument() - expect(screen.getByText('complete-org')).toBeInTheDocument() - expect(screen.getByText('complete-plugin')).toBeInTheDocument() - expect(screen.getByText('Tool')).toBeInTheDocument() - expect(screen.getByTestId('partner-badge')).toBeInTheDocument() - expect(screen.getByTestId('verified-badge')).toBeInTheDocument() - expect(screen.getByText('5,000')).toBeInTheDocument() - expect(screen.getByText('search')).toBeInTheDocument() - expect(screen.getByText('api')).toBeInTheDocument() - }) - - it('should render loading state correctly', () => { - const plugin = createMockPlugin() - - render( - <Card - payload={plugin} - isLoading={true} - loadingFileName="loading-plugin.zip" - />, - ) - - expect(screen.getByTestId('skeleton-container')).toBeInTheDocument() - expect(screen.getByText('loading-plugin.zip')).toBeInTheDocument() - expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument() - }) - - it('should handle installed state with footer', () => { - const plugin = createMockPlugin() - - render( - <Card - payload={plugin} - installed={true} - footer={<CardMoreInfo downloadCount={100} tags={['tag1']} />} - />, - ) - - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() - expect(screen.getByText('100')).toBeInTheDocument() - }) - }) - - describe('Component Hierarchy', () => { - it('should render Icon inside Card', () => { - const plugin = createMockPlugin({ - icon: '/test-icon.png', - }) - - const { container } = render(<Card payload={plugin} />) - - // Icon should be rendered with background image - const iconElement = container.querySelector('[style*="background-image"]') - expect(iconElement).toBeInTheDocument() - }) - - it('should render Title inside Card', () => { - const plugin = createMockPlugin({ - label: { 'en-US': 'Test Title' }, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Test Title')).toBeInTheDocument() - }) - - it('should render Description inside Card', () => { - const plugin = createMockPlugin({ - brief: { 'en-US': 'Test Description' }, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Test Description')).toBeInTheDocument() - }) - - it('should render OrgInfo inside Card', () => { - const plugin = createMockPlugin({ - org: 'test-org', - name: 'test-name', - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('test-org')).toBeInTheDocument() - expect(screen.getByText('/')).toBeInTheDocument() - expect(screen.getByText('test-name')).toBeInTheDocument() - }) - - it('should render CornerMark inside Card', () => { - const plugin = createMockPlugin({ - category: PluginCategoryEnum.model, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Model')).toBeInTheDocument() - expect(screen.getByTestId('left-corner')).toBeInTheDocument() - }) - }) - }) - - // ================================ - // Accessibility Tests - // ================================ - describe('Accessibility', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should have accessible text content', () => { - const plugin = createMockPlugin({ - label: { 'en-US': 'Accessible Plugin' }, - brief: { 'en-US': 'This plugin is accessible' }, - }) - - render(<Card payload={plugin} />) - - expect(screen.getByText('Accessible Plugin')).toBeInTheDocument() - expect(screen.getByText('This plugin is accessible')).toBeInTheDocument() - }) - - it('should have title attribute on tags', () => { - render(<CardMoreInfo downloadCount={100} tags={['search']} />) - - expect(screen.getByTitle('# search')).toBeInTheDocument() - }) - - it('should have semantic structure', () => { - const plugin = createMockPlugin() - const { container } = render(<Card payload={plugin} />) - - // Card should have proper container structure - expect(container.firstChild).toHaveClass('rounded-xl') - }) - }) - - // ================================ - // Performance Tests - // ================================ - describe('Performance', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render multiple cards efficiently', () => { - const plugins = Array.from({ length: 50 }, (_, i) => - createMockPlugin({ - name: `plugin-${i}`, - label: { 'en-US': `Plugin ${i}` }, - })) - - const startTime = performance.now() - const { container } = render( - <div> - {plugins.map(plugin => ( - <Card key={plugin.name} payload={plugin} /> - ))} - </div>, - ) - const endTime = performance.now() - - // Should render all cards - const cards = container.querySelectorAll('.rounded-xl') - expect(cards.length).toBe(50) - - // Should render within reasonable time (less than 1 second) - expect(endTime - startTime).toBeLessThan(1000) - }) - - it('should handle CardMoreInfo with many tags', () => { - const tags = Array.from({ length: 20 }, (_, i) => `tag-${i}`) - - const startTime = performance.now() - render(<CardMoreInfo downloadCount={1000} tags={tags} />) - const endTime = performance.now() - - expect(endTime - startTime).toBeLessThan(100) - }) - }) -}) diff --git a/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..b0e3ec5832 --- /dev/null +++ b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts @@ -0,0 +1,166 @@ +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' }), + ) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/utils.spec.ts b/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts similarity index 99% rename from web/app/components/plugins/install-plugin/utils.spec.ts rename to web/app/components/plugins/install-plugin/__tests__/utils.spec.ts index 9a759b8026..b13ebffe2f 100644 --- a/web/app/components/plugins/install-plugin/utils.spec.ts +++ b/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts @@ -1,12 +1,12 @@ -import type { PluginDeclaration, PluginManifestInMarket } from '../types' +import type { PluginDeclaration, PluginManifestInMarket } from '../../types' import { describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../types' +import { PluginCategoryEnum } from '../../types' import { convertRepoToUrl, parseGitHubUrl, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps, -} from './utils' +} from '../utils' // Mock es-toolkit/compat vi.mock('es-toolkit/compat', () => ({ diff --git a/web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts b/web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts new file mode 100644 index 0000000000..2fd46a07cd --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/check-task-status.spec.ts @@ -0,0 +1,125 @@ +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) + }) +}) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx new file mode 100644 index 0000000000..a1d6e9ebb1 --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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('plugin.installModal.installedSuccessfullyDesc')).toBeInTheDocument() + }) + + it('should render failure message when failed', () => { + render(<Installed isFailed={true} onCancel={vi.fn()} />) + + expect(screen.getByText('plugin.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('common.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() + }) +}) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx new file mode 100644 index 0000000000..cfb548c602 --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/loading-error.spec.tsx @@ -0,0 +1,46 @@ +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/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('plugin.installModal.pluginLoadError')).toBeInTheDocument() + expect(screen.getByText('plugin.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('group-icon')).toBeInTheDocument() + }) + + it('should render loading placeholder', () => { + render(<LoadingError />) + + expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/loading.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/loading.spec.tsx new file mode 100644 index 0000000000..aea928f099 --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/loading.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, 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() + }) +}) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx new file mode 100644 index 0000000000..bc61d66091 --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/version.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, 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() + }) +}) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx b/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx new file mode 100644 index 0000000000..232856e651 --- /dev/null +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx @@ -0,0 +1,79 @@ +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() + }) +}) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-hide-logic.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-hide-logic.spec.ts new file mode 100644 index 0000000000..5cbf117c6e --- /dev/null +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-hide-logic.spec.ts @@ -0,0 +1,76 @@ +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() + }) +}) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts new file mode 100644 index 0000000000..fa01b63b5a --- /dev/null +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts @@ -0,0 +1,149 @@ +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) + }) +}) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts new file mode 100644 index 0000000000..ce228d923f --- /dev/null +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts @@ -0,0 +1,168 @@ +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() + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/index.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-bundle/index.spec.tsx rename to web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx index 1b70cfb5c7..777a5174c6 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx @@ -1,14 +1,14 @@ -import type { Dependency, GitHubItemAndMarketPlaceDependency, InstallStatus, PackageDependency, Plugin, PluginDeclaration, VersionProps } from '../../types' +import type { Dependency, GitHubItemAndMarketPlaceDependency, InstallStatus, PackageDependency, Plugin, PluginDeclaration, VersionProps } from '../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { InstallStep, PluginCategoryEnum } from '../../types' -import InstallBundle, { InstallType } from './index' -import GithubItem from './item/github-item' -import LoadedItem from './item/loaded-item' -import MarketplaceItem from './item/marketplace-item' -import PackageItem from './item/package-item' -import ReadyToInstall from './ready-to-install' -import Installed from './steps/installed' +import { InstallStep, PluginCategoryEnum } from '../../../types' +import InstallBundle, { InstallType } from '../index' +import GithubItem from '../item/github-item' +import LoadedItem from '../item/loaded-item' +import MarketplaceItem from '../item/marketplace-item' +import PackageItem from '../item/package-item' +import ReadyToInstall from '../ready-to-install' +import Installed from '../steps/installed' // Factory functions for test data const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({ @@ -143,19 +143,19 @@ let mockHideLogicState = { setIsInstalling: vi.fn(), handleStartToInstall: vi.fn(), } -vi.mock('../hooks/use-hide-logic', () => ({ +vi.mock('../../hooks/use-hide-logic', () => ({ default: () => mockHideLogicState, })) // Mock useGetIcon hook -vi.mock('../base/use-get-icon', () => ({ +vi.mock('../../base/use-get-icon', () => ({ default: () => ({ getIconUrl: (icon: string) => icon || 'default-icon.png', }), })) // Mock usePluginInstallLimit hook -vi.mock('../hooks/use-install-plugin-limit', () => ({ +vi.mock('../../hooks/use-install-plugin-limit', () => ({ default: () => ({ canInstall: true }), pluginInstallLimit: () => ({ canInstall: true }), })) @@ -190,22 +190,22 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ })) // Mock checkTaskStatus -vi.mock('../base/check-task-status', () => ({ +vi.mock('../../base/check-task-status', () => ({ default: () => ({ check: vi.fn(), stop: vi.fn() }), })) // Mock useRefreshPluginList -vi.mock('../hooks/use-refresh-plugin-list', () => ({ +vi.mock('../../hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: vi.fn() }), })) // Mock useCheckInstalled -vi.mock('../hooks/use-check-installed', () => ({ +vi.mock('../../hooks/use-check-installed', () => ({ default: () => ({ installedInfo: {} }), })) // Mock ReadyToInstall child component to test InstallBundle in isolation -vi.mock('./ready-to-install', () => ({ +vi.mock('../ready-to-install', () => ({ default: ({ step, onStepChange, diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.spec.tsx rename to web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx index 48f0703a4b..cdaa471496 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx @@ -1,9 +1,9 @@ -import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../types' +import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../../types' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../../../types' -import InstallMulti from './install-multi' +import { PluginCategoryEnum } from '../../../../types' +import InstallMulti from '../install-multi' // ==================== Mock Setup ==================== @@ -62,12 +62,12 @@ vi.mock('@/context/global-public-context', () => ({ })) // Mock pluginInstallLimit -vi.mock('../../hooks/use-install-plugin-limit', () => ({ +vi.mock('../../../hooks/use-install-plugin-limit', () => ({ pluginInstallLimit: () => ({ canInstall: true }), })) // Mock child components -vi.mock('../item/github-item', () => ({ +vi.mock('../../item/github-item', () => ({ default: vi.fn().mockImplementation(({ checked, onCheckedChange, @@ -120,7 +120,7 @@ vi.mock('../item/github-item', () => ({ }), })) -vi.mock('../item/marketplace-item', () => ({ +vi.mock('../../item/marketplace-item', () => ({ default: vi.fn().mockImplementation(({ checked, onCheckedChange, @@ -142,7 +142,7 @@ vi.mock('../item/marketplace-item', () => ({ )), })) -vi.mock('../item/package-item', () => ({ +vi.mock('../../item/package-item', () => ({ default: vi.fn().mockImplementation(({ checked, onCheckedChange, @@ -163,7 +163,7 @@ vi.mock('../item/package-item', () => ({ )), })) -vi.mock('../../base/loading-error', () => ({ +vi.mock('../../../base/loading-error', () => ({ default: () => <div data-testid="loading-error">Loading Error</div>, })) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-bundle/steps/install.spec.tsx rename to web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx index 435d475553..3e848b35f4 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx @@ -1,8 +1,8 @@ -import type { Dependency, InstallStatusResponse, PackageDependency } from '../../../types' +import type { Dependency, InstallStatusResponse, PackageDependency } from '../../../../types' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, TaskStatus } from '../../../types' -import Install from './install' +import { PluginCategoryEnum, TaskStatus } from '../../../../types' +import Install from '../install' // ==================== Mock Setup ==================== @@ -42,7 +42,7 @@ vi.mock('@/service/use-plugins', () => ({ // Mock checkTaskStatus const mockCheck = vi.fn() const mockStop = vi.fn() -vi.mock('../../base/check-task-status', () => ({ +vi.mock('../../../base/check-task-status', () => ({ default: () => ({ check: mockCheck, stop: mockStop, @@ -51,7 +51,7 @@ vi.mock('../../base/check-task-status', () => ({ // Mock useRefreshPluginList const mockRefreshPluginList = vi.fn() -vi.mock('../../hooks/use-refresh-plugin-list', () => ({ +vi.mock('../../../hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: mockRefreshPluginList, }), @@ -69,7 +69,7 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ })) // Mock InstallMulti component with forwardRef support -vi.mock('./install-multi', async () => { +vi.mock('../install-multi', async () => { const React = await import('react') const createPlugin = (index: number) => ({ @@ -838,7 +838,7 @@ describe('Install Component', () => { // ==================== Memoization Test ==================== describe('Memoization', () => { it('should be memoized', async () => { - const InstallModule = await import('./install') + const InstallModule = await import('../install') // memo returns an object with $$typeof expect(typeof InstallModule.default).toBe('object') }) diff --git a/web/app/components/plugins/install-plugin/install-from-github/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/install-plugin/install-from-github/index.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx index 5266f810f1..0fe6b88ed8 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx @@ -1,9 +1,9 @@ -import type { GitHubRepoReleaseResponse, PluginDeclaration, PluginManifestInMarket, UpdateFromGitHubPayload } from '../../types' +import type { GitHubRepoReleaseResponse, PluginDeclaration, PluginManifestInMarket, UpdateFromGitHubPayload } from '../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../../types' -import { convertRepoToUrl, parseGitHubUrl, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../utils' -import InstallFromGitHub from './index' +import { PluginCategoryEnum } from '../../../types' +import { convertRepoToUrl, parseGitHubUrl, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../../utils' +import InstallFromGitHub from '../index' // Factory functions for test data (defined before mocks that use them) const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ @@ -69,12 +69,12 @@ vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({ })) const mockFetchReleases = vi.fn() -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useGitHubReleases: () => ({ fetchReleases: mockFetchReleases }), })) const mockRefreshPluginList = vi.fn() -vi.mock('../hooks/use-refresh-plugin-list', () => ({ +vi.mock('../../hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: mockRefreshPluginList }), })) @@ -84,12 +84,12 @@ let mockHideLogicState = { setIsInstalling: vi.fn(), handleStartToInstall: vi.fn(), } -vi.mock('../hooks/use-hide-logic', () => ({ +vi.mock('../../hooks/use-hide-logic', () => ({ default: () => mockHideLogicState, })) // Mock child components -vi.mock('./steps/setURL', () => ({ +vi.mock('../steps/setURL', () => ({ default: ({ repoUrl, onChange, onNext, onCancel }: { repoUrl: string onChange: (value: string) => void @@ -108,7 +108,7 @@ vi.mock('./steps/setURL', () => ({ ), })) -vi.mock('./steps/selectPackage', () => ({ +vi.mock('../steps/selectPackage', () => ({ default: ({ repoUrl, selectedVersion, @@ -170,7 +170,7 @@ vi.mock('./steps/selectPackage', () => ({ ), })) -vi.mock('./steps/loaded', () => ({ +vi.mock('../steps/loaded', () => ({ default: ({ uniqueIdentifier, payload, @@ -208,7 +208,7 @@ vi.mock('./steps/loaded', () => ({ ), })) -vi.mock('../base/installed', () => ({ +vi.mock('../../base/installed', () => ({ default: ({ payload, isFailed, errMsg, onCancel }: { payload: PluginDeclaration | null isFailed: boolean diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-from-github/steps/loaded.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx index 3c70c35dc7..82eedad219 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx @@ -1,8 +1,8 @@ -import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../types' +import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, TaskStatus } from '../../../types' -import Loaded from './loaded' +import { PluginCategoryEnum, TaskStatus } from '../../../../types' +import Loaded from '../loaded' // Mock dependencies const mockUseCheckInstalled = vi.fn() @@ -23,12 +23,12 @@ vi.mock('@/service/use-plugins', () => ({ })) const mockCheck = vi.fn() -vi.mock('../../base/check-task-status', () => ({ +vi.mock('../../../base/check-task-status', () => ({ default: () => ({ check: mockCheck }), })) // Mock Card component -vi.mock('../../../card', () => ({ +vi.mock('../../../../card', () => ({ default: ({ payload, titleLeft }: { payload: Plugin, titleLeft?: React.ReactNode }) => ( <div data-testid="plugin-card"> <span data-testid="card-name">{payload.name}</span> @@ -38,7 +38,7 @@ vi.mock('../../../card', () => ({ })) // Mock Version component -vi.mock('../../base/version', () => ({ +vi.mock('../../../base/version', () => ({ default: ({ hasInstalled, installedVersion, toInstallVersion }: { hasInstalled: boolean installedVersion?: string diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/selectPackage.spec.tsx similarity index 99% rename from web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/selectPackage.spec.tsx index 71f0e5e497..060a5c92a1 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/selectPackage.spec.tsx @@ -1,13 +1,13 @@ -import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types' +import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../../types' import type { Item } from '@/app/components/base/select' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../../../types' -import SelectPackage from './selectPackage' +import { PluginCategoryEnum } from '../../../../types' +import SelectPackage from '../selectPackage' // Mock the useGitHubUpload hook const mockHandleUpload = vi.fn() -vi.mock('../../hooks', () => ({ +vi.mock('../../../hooks', () => ({ useGitHubUpload: () => ({ handleUpload: mockHandleUpload }), })) diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx similarity index 99% rename from web/app/components/plugins/install-plugin/install-from-github/steps/setURL.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx index 11fa3057e3..fca64ac096 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/setURL.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import SetURL from './setURL' +import SetURL from '../setURL' describe('SetURL', () => { const defaultProps = { diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/install-plugin/install-from-local-package/index.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx index 18225dd48d..cac6250550 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { Dependency, PluginDeclaration } from '../../types' +import type { Dependency, PluginDeclaration } from '../../../types' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { InstallStep, PluginCategoryEnum } from '../../types' -import InstallFromLocalPackage from './index' +import { InstallStep, PluginCategoryEnum } from '../../../types' +import InstallFromLocalPackage from '../index' // Factory functions for test data const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ @@ -64,7 +64,7 @@ let mockHideLogicState = { setIsInstalling: vi.fn(), handleStartToInstall: vi.fn(), } -vi.mock('../hooks/use-hide-logic', () => ({ +vi.mock('../../hooks/use-hide-logic', () => ({ default: () => mockHideLogicState, })) @@ -73,7 +73,7 @@ let uploadingOnPackageUploaded: ((result: { uniqueIdentifier: string, manifest: let uploadingOnBundleUploaded: ((result: Dependency[]) => void) | null = null let _uploadingOnFailed: ((errorMsg: string) => void) | null = null -vi.mock('./steps/uploading', () => ({ +vi.mock('../steps/uploading', () => ({ default: ({ isBundle, file, @@ -127,7 +127,7 @@ let _packageStepChangeCallback: ((step: InstallStep) => void) | null = null let _packageSetIsInstallingCallback: ((isInstalling: boolean) => void) | null = null let _packageOnErrorCallback: ((errorMsg: string) => void) | null = null -vi.mock('./ready-to-install', () => ({ +vi.mock('../ready-to-install', () => ({ default: ({ step, onStepChange, @@ -192,7 +192,7 @@ vi.mock('./ready-to-install', () => ({ let _bundleStepChangeCallback: ((step: InstallStep) => void) | null = null let _bundleSetIsInstallingCallback: ((isInstalling: boolean) => void) | null = null -vi.mock('../install-bundle/ready-to-install', () => ({ +vi.mock('../../install-bundle/ready-to-install', () => ({ default: ({ step, onStepChange, diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/ready-to-install.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-local-package/__tests__/ready-to-install.spec.tsx index 6597cccd9b..05b7625d02 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/__tests__/ready-to-install.spec.tsx @@ -1,8 +1,8 @@ -import type { PluginDeclaration } from '../../types' +import type { PluginDeclaration } from '../../../types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { InstallStep, PluginCategoryEnum } from '../../types' -import ReadyToInstall from './ready-to-install' +import { InstallStep, PluginCategoryEnum } from '../../../types' +import ReadyToInstall from '../ready-to-install' // Factory function for test data const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ @@ -29,7 +29,7 @@ const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginD // Mock external dependencies const mockRefreshPluginList = vi.fn() -vi.mock('../hooks/use-refresh-plugin-list', () => ({ +vi.mock('../../hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: mockRefreshPluginList, }), @@ -41,7 +41,7 @@ let _installOnFailed: ((message?: string) => void) | null = null let _installOnCancel: (() => void) | null = null let _installOnStartToInstall: (() => void) | null = null -vi.mock('./steps/install', () => ({ +vi.mock('../steps/install', () => ({ default: ({ uniqueIdentifier, payload, @@ -87,7 +87,7 @@ vi.mock('./steps/install', () => ({ })) // Mock Installed component -vi.mock('../base/installed', () => ({ +vi.mock('../../base/installed', () => ({ default: ({ payload, isFailed, diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx similarity index 95% rename from web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx index 7f95eb0b35..8fa27a4c97 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx @@ -1,8 +1,8 @@ -import type { PluginDeclaration } from '../../../types' +import type { PluginDeclaration } from '../../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, TaskStatus } from '../../../types' -import Install from './install' +import { PluginCategoryEnum, TaskStatus } from '../../../../types' +import Install from '../install' // Factory function for test data const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ @@ -50,7 +50,7 @@ vi.mock('@/service/plugins', () => ({ const mockCheck = vi.fn() const mockStop = vi.fn() -vi.mock('../../base/check-task-status', () => ({ +vi.mock('../../../base/check-task-status', () => ({ default: () => ({ check: mockCheck, stop: mockStop, @@ -64,22 +64,7 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal<typeof import('react-i18next')>() - const { createReactI18nextMock } = await import('@/test/i18n-mock') - return { - ...actual, - ...createReactI18nextMock(), - Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => ( - <span data-testid="trans"> - {i18nKey} - {components?.trustSource} - </span> - ), - } -}) - -vi.mock('../../../card', () => ({ +vi.mock('../../../../card', () => ({ default: ({ payload, titleLeft }: { payload: Record<string, unknown> titleLeft?: React.ReactNode @@ -91,7 +76,7 @@ vi.mock('../../../card', () => ({ ), })) -vi.mock('../../base/version', () => ({ +vi.mock('../../../base/version', () => ({ default: ({ hasInstalled, installedVersion, toInstallVersion }: { hasInstalled: boolean installedVersion?: string @@ -105,7 +90,7 @@ vi.mock('../../base/version', () => ({ ), })) -vi.mock('../../utils', () => ({ +vi.mock('../../../utils', () => ({ pluginManifestToCardPluginProps: (manifest: PluginDeclaration) => ({ name: manifest.name, author: manifest.author, @@ -148,7 +133,7 @@ describe('Install', () => { it('should render trust source message', () => { render(<Install {...defaultProps} />) - expect(screen.getByTestId('trans')).toBeInTheDocument() + expect(screen.getByText('installModal.fromTrustSource')).toBeInTheDocument() }) it('should render plugin card', () => { diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx index 35256b6633..aace5dcbe9 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/uploading.spec.tsx @@ -1,9 +1,9 @@ -import type { Dependency, PluginDeclaration } from '../../../types' +import type { Dependency, PluginDeclaration } from '../../../../types' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../../../types' -import Uploading from './uploading' +import { PluginCategoryEnum } from '../../../../types' +import Uploading from '../uploading' // Factory function for test data const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ @@ -48,7 +48,7 @@ vi.mock('@/service/plugins', () => ({ uploadFile: (...args: unknown[]) => mockUploadFile(...args), })) -vi.mock('../../../card', () => ({ +vi.mock('../../../../card', () => ({ default: ({ payload, isLoading, loadingFileName }: { payload: { name: string } isLoading?: boolean diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/install-plugin/install-from-marketplace/index.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-marketplace/__tests__/index.spec.tsx index b844c14147..18fa634202 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { Dependency, Plugin, PluginManifestInMarket } from '../../types' +import type { Dependency, Plugin, PluginManifestInMarket } from '../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { InstallStep, PluginCategoryEnum } from '../../types' -import InstallFromMarketplace from './index' +import { InstallStep, PluginCategoryEnum } from '../../../types' +import InstallFromMarketplace from '../index' // Factory functions for test data // Use type casting to avoid strict locale requirements in tests @@ -69,7 +69,7 @@ const createMockDependencies = (): Dependency[] => [ // Mock external dependencies const mockRefreshPluginList = vi.fn() -vi.mock('../hooks/use-refresh-plugin-list', () => ({ +vi.mock('../../hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: mockRefreshPluginList }), })) @@ -79,12 +79,12 @@ let mockHideLogicState = { setIsInstalling: vi.fn(), handleStartToInstall: vi.fn(), } -vi.mock('../hooks/use-hide-logic', () => ({ +vi.mock('../../hooks/use-hide-logic', () => ({ default: () => mockHideLogicState, })) // Mock child components -vi.mock('./steps/install', () => ({ +vi.mock('../steps/install', () => ({ default: ({ uniqueIdentifier, payload, @@ -113,7 +113,7 @@ vi.mock('./steps/install', () => ({ ), })) -vi.mock('../install-bundle/ready-to-install', () => ({ +vi.mock('../../install-bundle/ready-to-install', () => ({ default: ({ step, onStepChange, @@ -145,7 +145,7 @@ vi.mock('../install-bundle/ready-to-install', () => ({ ), })) -vi.mock('../base/installed', () => ({ +vi.mock('../../base/installed', () => ({ default: ({ payload, isMarketPayload, diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx similarity index 96% rename from web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx rename to web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx index b283f0ebe8..93da618486 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx @@ -1,9 +1,9 @@ -import type { Plugin, PluginManifestInMarket } from '../../../types' +import type { Plugin, PluginManifestInMarket } from '../../../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { act } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, TaskStatus } from '../../../types' -import Install from './install' +import { PluginCategoryEnum, TaskStatus } from '../../../../types' +import Install from '../install' // Factory functions for test data const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({ @@ -64,7 +64,7 @@ let mockLangGeniusVersionInfo = { current_version: '1.0.0' } // Mock useCheckInstalled vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ - default: ({ pluginIds }: { pluginIds: string[], enabled: boolean }) => ({ + default: ({ pluginIds: _pluginIds }: { pluginIds: string[], enabled: boolean }) => ({ installedInfo: mockInstalledInfo, isLoading: mockIsLoading, error: null, @@ -88,7 +88,7 @@ vi.mock('@/service/use-plugins', () => ({ })) // Mock checkTaskStatus -vi.mock('../../base/check-task-status', () => ({ +vi.mock('../../../base/check-task-status', () => ({ default: () => ({ check: mockCheckTaskStatus, stop: mockStopTaskStatus, @@ -103,20 +103,20 @@ vi.mock('@/context/app-context', () => ({ })) // Mock useInstallPluginLimit -vi.mock('../../hooks/use-install-plugin-limit', () => ({ +vi.mock('../../../hooks/use-install-plugin-limit', () => ({ default: () => ({ canInstall: mockCanInstall }), })) // Mock Card component -vi.mock('../../../card', () => ({ - default: ({ payload, titleLeft, className, limitedInstall }: { - payload: any +vi.mock('../../../../card', () => ({ + default: ({ payload, titleLeft, className: _className, limitedInstall }: { + payload: Record<string, unknown> titleLeft?: React.ReactNode className?: string limitedInstall?: boolean }) => ( <div data-testid="plugin-card"> - <span data-testid="card-payload-name">{payload?.name}</span> + <span data-testid="card-payload-name">{String(payload?.name ?? '')}</span> <span data-testid="card-limited-install">{limitedInstall ? 'true' : 'false'}</span> {!!titleLeft && <div data-testid="card-title-left">{titleLeft}</div>} </div> @@ -124,7 +124,7 @@ vi.mock('../../../card', () => ({ })) // Mock Version component -vi.mock('../../base/version', () => ({ +vi.mock('../../../base/version', () => ({ default: ({ hasInstalled, installedVersion, toInstallVersion }: { hasInstalled: boolean installedVersion?: string @@ -139,7 +139,7 @@ vi.mock('../../base/version', () => ({ })) // Mock utils -vi.mock('../../utils', () => ({ +vi.mock('../../../utils', () => ({ pluginManifestInMarketToPluginProps: (payload: PluginManifestInMarket) => ({ name: payload.name, icon: payload.icon, @@ -255,7 +255,7 @@ describe('Install Component (steps/install.tsx)', () => { }) it('should fallback to latest_version when version is undefined', () => { - const manifest = createMockManifest({ version: undefined as any, latest_version: '3.0.0' }) + const manifest = createMockManifest({ version: undefined as unknown as string, latest_version: '3.0.0' }) render(<Install {...defaultProps} payload={manifest} />) expect(screen.getByTestId('to-install-version')).toHaveTextContent('3.0.0') @@ -701,7 +701,7 @@ describe('Install Component (steps/install.tsx)', () => { }) it('should handle null current_version in langGeniusVersionInfo', () => { - mockLangGeniusVersionInfo = { current_version: null as any } + mockLangGeniusVersionInfo = { current_version: null as unknown as string } mockPluginDeclaration = { manifest: { meta: { minimum_dify_version: '1.0.0' } }, } diff --git a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx new file mode 100644 index 0000000000..ddbef3542a --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx @@ -0,0 +1,601 @@ +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) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/__tests__/index.spec.tsx new file mode 100644 index 0000000000..458d444370 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/index.spec.tsx @@ -0,0 +1,15 @@ +import { describe, it } from 'vitest' + +// The Marketplace index component is an async Server Component +// that cannot be unit tested in jsdom. It is covered by integration tests. +// +// All sub-module tests have been moved to dedicated spec files: +// - constants.spec.ts (DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD, PLUGIN_TYPE_SEARCH_MAP) +// - utils.spec.ts (getPluginIconInMarketplace, getFormattedPlugin, getPluginLinkInMarketplace, etc.) +// - hooks.spec.tsx (useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceContainerScroll) + +describe('Marketplace index', () => { + it('should be covered by dedicated sub-module specs', () => { + // Placeholder to document the split + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts new file mode 100644 index 0000000000..91beed2630 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts @@ -0,0 +1,317 @@ +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', + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/description/index.spec.tsx b/web/app/components/plugins/marketplace/description/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/marketplace/description/index.spec.tsx rename to web/app/components/plugins/marketplace/description/__tests__/index.spec.tsx index 054949ee1f..8d7cb6f435 100644 --- a/web/app/components/plugins/marketplace/description/index.spec.tsx +++ b/web/app/components/plugins/marketplace/description/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Description from './index' +import Description from '../index' // ================================ // Mock external dependencies diff --git a/web/app/components/plugins/marketplace/empty/index.spec.tsx b/web/app/components/plugins/marketplace/empty/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/marketplace/empty/index.spec.tsx rename to web/app/components/plugins/marketplace/empty/__tests__/index.spec.tsx index bc8e701dfc..7202907b50 100644 --- a/web/app/components/plugins/marketplace/empty/index.spec.tsx +++ b/web/app/components/plugins/marketplace/empty/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Empty from './index' -import Line from './line' +import Empty from '../index' +import Line from '../line' // ================================ // Mock external dependencies only diff --git a/web/app/components/plugins/marketplace/hooks.spec.tsx b/web/app/components/plugins/marketplace/hooks.spec.tsx new file mode 100644 index 0000000000..89abbe5025 --- /dev/null +++ b/web/app/components/plugins/marketplace/hooks.spec.tsx @@ -0,0 +1,597 @@ +import { render, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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) + }) +}) diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx deleted file mode 100644 index 1c0c700177..0000000000 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ /dev/null @@ -1,1828 +0,0 @@ -import type { MarketplaceCollection } from './types' -import type { Plugin } from '@/app/components/plugins/types' -import { act, render, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '@/app/components/plugins/types' - -// ================================ -// Import Components After Mocks -// ================================ - -// Note: Import after mocks are set up -import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' -import { - getFormattedPlugin, - getMarketplaceListCondition, - getMarketplaceListFilterType, - getPluginDetailLinkInMarketplace, - getPluginIconInMarketplace, - getPluginLinkInMarketplace, -} from './utils' - -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock i18next-config -vi.mock('@/i18n-config/i18next-config', () => ({ - default: { - getFixedT: (_locale: string) => (key: string, options?: Record<string, unknown>) => { - if (options && options.ns) { - return `${options.ns}.${key}` - } - else { - return key - } - }, - }, -})) - -// Mock use-query-params hook -const mockSetUrlFilters = vi.fn() -vi.mock('@/hooks/use-query-params', () => ({ - useMarketplaceFilters: () => [ - { q: '', tags: [], category: '' }, - mockSetUrlFilters, - ], -})) - -// Mock use-plugins service -const mockInstalledPluginListData = { - plugins: [], -} -vi.mock('@/service/use-plugins', () => ({ - useInstalledPluginList: (_enabled: boolean) => ({ - data: mockInstalledPluginListData, - isSuccess: true, - }), -})) - -// Mock tanstack query -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 }) => { - // Capture queryFn for later testing - capturedQueryFn = queryFn - // Always call queryFn to increase coverage (including when enabled is false) - 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, enabled: _enabled }: { - queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown> - getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined - enabled: boolean - }) => { - // Capture queryFn and getNextPageParam for later testing - capturedInfiniteQueryFn = queryFn - capturedGetNextPageParam = getNextPageParam - // Always call queryFn to increase coverage (including when enabled is false for edge cases) - if (queryFn) { - const controller = new AbortController() - queryFn({ pageParam: 1, signal: controller.signal }).catch(() => {}) - } - // Call getNextPageParam to increase coverage - if (getNextPageParam) { - // Test with more data available - getNextPageParam({ page: 1, page_size: 40, total: 100 }) - // Test with no more data - 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(), - })), -})) - -// Mock ahooks -vi.mock('ahooks', () => ({ - useDebounceFn: (fn: (...args: unknown[]) => void) => ({ - run: fn, - cancel: vi.fn(), - }), -})) - -// Mock marketplace service -let mockPostMarketplaceShouldFail = false -const mockPostMarketplaceResponse: { - data: { - plugins: Array<{ type: string, org: string, name: string, tags: unknown[] }> - bundles: Array<{ type: string, org: string, name: string, tags: unknown[] }> - total: number - } -} = { - data: { - plugins: [ - { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, - { type: 'plugin', org: 'test', name: 'plugin2', tags: [] }, - ], - bundles: [], - total: 2, - }, -} -vi.mock('@/service/base', () => ({ - postMarketplace: vi.fn(() => { - if (mockPostMarketplaceShouldFail) - return Promise.reject(new Error('Mock API error')) - return Promise.resolve(mockPostMarketplaceResponse) - }), -})) - -// 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, _params?: Record<string, string | undefined>) => `https://marketplace.dify.ai${path}`, -})) - -// Mock marketplace client used by marketplace utils -vi.mock('@/service/client', () => ({ - marketplaceClient: { - collections: vi.fn(async (_args?: unknown, _opts?: { signal?: AbortSignal }) => ({ - 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 (_args?: unknown, _opts?: { signal?: AbortSignal }) => ({ - data: { - plugins: [ - { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, - ], - }, - })), - // Some utils paths may call searchAdvanced; provide a minimal stub - searchAdvanced: vi.fn(async (_args?: unknown, _opts?: { signal?: AbortSignal }) => ({ - data: { - plugins: [ - { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, - ], - total: 1, - }, - })), - }, -})) - -// Mock context/query-client -vi.mock('@/context/query-client', () => ({ - TanstackQueryInitializer: ({ children }: { children: React.ReactNode }) => <div data-testid="query-initializer">{children}</div>, -})) - -// Mock i18n-config/server -vi.mock('@/i18n-config/server', () => ({ - getLocaleOnServer: vi.fn(() => Promise.resolve('en-US')), - getTranslation: vi.fn(() => Promise.resolve({ t: (key: string) => key })), -})) - -// Mock useTheme hook -const mockTheme = 'light' -vi.mock('@/hooks/use-theme', () => ({ - default: () => ({ - theme: mockTheme, - }), -})) - -// Mock next-themes -vi.mock('next-themes', () => ({ - useTheme: () => ({ - theme: mockTheme, - }), -})) - -// Mock useLocale context -vi.mock('@/context/i18n', () => ({ - useLocale: () => 'en-US', -})) - -// Mock i18n-config/language -vi.mock('@/i18n-config/language', () => ({ - getLanguage: (locale: string) => locale || 'en-US', -})) - -// Mock global fetch for utils testing -const originalFetch = globalThis.fetch - -// Mock useTags hook -const mockTags = [ - { name: 'search', label: 'Search' }, - { name: 'image', label: 'Image' }, - { name: 'agent', label: 'Agent' }, -] - -const mockTagsMap = mockTags.reduce((acc, tag) => { - acc[tag.name] = tag - return acc -}, {} as Record<string, { name: string, label: string }>) - -vi.mock('@/app/components/plugins/hooks', () => ({ - useTags: () => ({ - tags: mockTags, - tagsMap: mockTagsMap, - getTagLabel: (name: string) => { - const tag = mockTags.find(t => t.name === name) - return tag?.label || name - }, - }), -})) - -// Mock plugins utils -vi.mock('../utils', () => ({ - getValidCategoryKeys: (category: string | undefined) => category || '', - getValidTagKeys: (tags: string[] | string | undefined) => { - if (Array.isArray(tags)) - return tags - if (typeof tags === 'string') - return tags.split(',').filter(Boolean) - return [] - }, -})) - -// Mock portal-to-follow-elem with shared open state -let mockPortalOpenState = false - -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { - children: React.ReactNode - open: boolean - }) => { - mockPortalOpenState = open - return ( - <div data-testid="portal-elem" data-open={open}> - {children} - </div> - ) - }, - PortalToFollowElemTrigger: ({ children, onClick, className }: { - children: React.ReactNode - onClick: () => void - className?: string - }) => ( - <div data-testid="portal-trigger" onClick={onClick} className={className}> - {children} - </div> - ), - PortalToFollowElemContent: ({ children, className }: { - children: React.ReactNode - className?: string - }) => { - if (!mockPortalOpenState) - return null - return ( - <div data-testid="portal-content" className={className}> - {children} - </div> - ) - }, -})) - -// Mock Card component -vi.mock('@/app/components/plugins/card', () => ({ - default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => ( - <div data-testid={`card-${payload.name}`}> - <div data-testid="card-name">{payload.name}</div> - {!!footer && <div data-testid="card-footer">{footer}</div>} - </div> - ), -})) - -// Mock CardMoreInfo component -vi.mock('@/app/components/plugins/card/card-more-info', () => ({ - default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => ( - <div data-testid="card-more-info"> - <span data-testid="download-count">{downloadCount}</span> - <span data-testid="tags">{tags.join(',')}</span> - </div> - ), -})) - -// Mock InstallFromMarketplace component -vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ - default: ({ onClose }: { onClose: () => void }) => ( - <div data-testid="install-from-marketplace"> - <button onClick={onClose} data-testid="close-install-modal">Close</button> - </div> - ), -})) - -// Mock base icons -vi.mock('@/app/components/base/icons/src/vender/other', () => ({ - Group: ({ className }: { className?: string }) => <span data-testid="group-icon" className={className} />, -})) - -vi.mock('@/app/components/base/icons/src/vender/plugin', () => ({ - Trigger: ({ className }: { className?: string }) => <span data-testid="trigger-icon" className={className} />, -})) - -// ================================ -// Test Data Factories -// ================================ - -const createMockPlugin = (overrides?: Partial<Plugin>): Plugin => ({ - type: 'plugin', - org: 'test-org', - name: `test-plugin-${Math.random().toString(36).substring(7)}`, - plugin_id: `plugin-${Math.random().toString(36).substring(7)}`, - 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' }, - description: { 'en-US': 'Test plugin full 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, -}) - -const createMockPluginList = (count: number): Plugin[] => - Array.from({ length: count }, (_, i) => - createMockPlugin({ - name: `plugin-${i}`, - plugin_id: `plugin-id-${i}`, - install_count: 1000 - i * 10, - })) - -const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({ - name: 'test-collection', - label: { 'en-US': 'Test Collection' }, - description: { 'en-US': 'Test collection description' }, - rule: 'test-rule', - created_at: '2024-01-01', - updated_at: '2024-01-01', - searchable: true, - search_params: { - query: '', - sort_by: 'install_count', - sort_order: 'DESC', - }, - ...overrides, -}) - -// ================================ -// Constants Tests -// ================================ -describe('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) - }) - }) -}) - -// ================================ -// PLUGIN_TYPE_SEARCH_MAP Tests -// ================================ -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') - }) -}) - -// ================================ -// Utils Tests -// ================================ -describe('utils', () => { - describe('getPluginIconInMarketplace', () => { - it('should return correct icon URL for regular plugin', () => { - 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', () => { - 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', () => { - 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', () => { - 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', () => { - 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', () => { - 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', () => { - 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', () => { - 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', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool') - }) - - it('should return category condition for model', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model') - }) - - it('should return category condition for agent', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy') - }) - - it('should return category condition for datasource', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource') - }) - - it('should return category condition for trigger', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger') - }) - - it('should return endpoint category for extension', () => { - expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint') - }) - - it('should return type condition for bundle', () => { - expect(getMarketplaceListCondition('bundle')).toBe('type=bundle') - }) - - it('should return empty string for all', () => { - expect(getMarketplaceListCondition('all')).toBe('') - }) - - it('should return empty string for unknown type', () => { - expect(getMarketplaceListCondition('unknown')).toBe('') - }) - }) - - describe('getMarketplaceListFilterType', () => { - it('should return undefined for all', () => { - expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined() - }) - - it('should return bundle for bundle', () => { - expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle') - }) - - it('should return plugin for other categories', () => { - 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') - }) - }) -}) - -// ================================ -// 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()) - - // Initial state - expect(result.current.marketplaceCollections).toBeUndefined() - }) - - it('should return marketplaceCollectionPluginsMap from data or override', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Initial state - 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 () => { - // The mock returns isFetching: false, isPending: false, so isLoading will be false - const { useMarketplacePluginsByCollectionId } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection')) - - // isLoading should be false since mock returns isFetching: false, isPending: false - 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')) - - // Hook should expose plugins property (may be array or fallback to empty array) - expect(result.current.plugins).toBeDefined() - }) -}) - -// ================================ -// useMarketplacePlugins Tests -// ================================ -describe('useMarketplacePlugins', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - 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 normalize params with default pageSize', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // queryPlugins will normalize params internally - expect(result.current.queryPlugins).toBeDefined() - }) - - it('should handle queryPlugins call without errors', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Call queryPlugins - 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()) - - // Initially, page should be 0 when no query params - expect(result.current.page).toBe(0) - }) - - it('should handle queryPlugins with category all', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(() => { - result.current.queryPlugins({ - query: 'test', - category: 'all', - sort_by: 'install_count', - sort_order: 'DESC', - }) - }).not.toThrow() - }) - - 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() - }) - - it('should handle queryPlugins with custom pageSize', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - expect(() => { - result.current.queryPlugins({ - query: 'test', - page_size: 100, - }) - }).not.toThrow() - }) -}) - -// ================================ -// Hooks queryFn Coverage Tests -// ================================ -describe('Hooks queryFn Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockInfiniteQueryData = undefined - }) - - it('should cover queryFn with pages data', async () => { - // Set mock data to have pages - mockInfiniteQueryData = { - pages: [ - { plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query to cover more code paths - result.current.queryPlugins({ - query: 'test', - category: 'tool', - }) - - // With mockInfiniteQueryData set, plugin flatMap should be covered - 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()) - - // After setting query params, plugins should be computed - result.current.queryPlugins({ - query: 'search', - }) - - // Hook returns page count based on mock data - expect(result.current.page).toBe(2) - }) - - it('should return undefined total when no query is set', async () => { - mockInfiniteQueryData = undefined - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // No query set, total should be undefined - expect(result.current.total).toBeUndefined() - }) - - it('should return total from first page when query is set and data exists', async () => { - mockInfiniteQueryData = { - pages: [ - { plugins: [], total: 50, page: 1, page_size: 40 }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - query: 'test', - }) - - // After query, page should be computed from pages length - expect(result.current.page).toBe(1) - }) - - it('should cover queryFn for plugins type search', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query with plugin type - result.current.queryPlugins({ - type: 'plugin', - query: 'search test', - category: 'model', - sort_by: 'version_updated_at', - sort_order: 'ASC', - }) - - expect(result.current).toBeDefined() - }) - - it('should cover queryFn for bundles type search', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query with bundle type - result.current.queryPlugins({ - type: 'bundle', - query: 'bundle search', - }) - - expect(result.current).toBeDefined() - }) - - it('should handle empty pages array', async () => { - mockInfiniteQueryData = { - pages: [], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - query: 'test', - }) - - expect(result.current.page).toBe(0) - }) - - it('should handle API error in queryFn', async () => { - mockPostMarketplaceShouldFail = true - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Even when API fails, hook should still work - result.current.queryPlugins({ - query: 'test that fails', - }) - - expect(result.current).toBeDefined() - mockPostMarketplaceShouldFail = false - }) -}) - -// ================================ -// Advanced Hook Integration Tests -// ================================ -describe('Advanced Hook Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - mockInfiniteQueryData = undefined - mockPostMarketplaceShouldFail = false - }) - - it('should test useMarketplaceCollectionsAndPlugins with query call', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Call the query function - result.current.queryMarketplaceCollectionsAndPlugins({ - condition: 'category=tool', - type: 'plugin', - }) - - expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() - }) - - it('should test useMarketplaceCollectionsAndPlugins with empty query', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Call with undefined (converts to empty object) - result.current.queryMarketplaceCollectionsAndPlugins() - - expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() - }) - - it('should test useMarketplacePluginsByCollectionId with different params', async () => { - const { useMarketplacePluginsByCollectionId } = await import('./hooks') - - // Test with various query params - const { result: result1 } = renderHook(() => - useMarketplacePluginsByCollectionId('collection-1', { - category: 'tool', - type: 'plugin', - exclude: ['plugin-to-exclude'], - })) - expect(result1.current).toBeDefined() - - const { result: result2 } = renderHook(() => - useMarketplacePluginsByCollectionId('collection-2', { - type: 'bundle', - })) - expect(result2.current).toBeDefined() - }) - - it('should test useMarketplacePlugins with various parameters', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Test with all possible parameters - result.current.queryPlugins({ - query: 'comprehensive test', - sort_by: 'install_count', - sort_order: 'DESC', - category: 'tool', - tags: ['tag1', 'tag2'], - exclude: ['excluded-plugin'], - type: 'plugin', - page_size: 50, - }) - - expect(result.current).toBeDefined() - - // Test reset - result.current.resetPlugins() - expect(result.current.plugins).toBeUndefined() - }) - - it('should test debounced query function', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Test debounced query - result.current.queryPluginsWithDebounced({ - query: 'debounced test', - }) - - // Cancel debounced query - result.current.cancelQueryPluginsWithDebounced() - - expect(result.current).toBeDefined() - }) -}) - -// ================================ -// Direct queryFn Coverage Tests -// ================================ -describe('Direct queryFn Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockInfiniteQueryData = undefined - mockPostMarketplaceShouldFail = false - capturedInfiniteQueryFn = null - capturedQueryFn = null - }) - - it('should directly test useMarketplacePlugins queryFn execution', async () => { - const { useMarketplacePlugins } = await import('./hooks') - - // First render to capture queryFn - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query to set queryParams and enable the query - result.current.queryPlugins({ - query: 'direct test', - category: 'tool', - sort_by: 'install_count', - sort_order: 'DESC', - page_size: 40, - }) - - // Now queryFn should be captured and enabled - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - // Call queryFn directly to cover internal logic - 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() - // This should trigger the catch block - 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()) - - // Trigger query to enable and capture queryFn - 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 queryFn with all category', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - category: 'all', - query: 'all category test', - }) - - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) - expect(response).toBeDefined() - } - }) - - it('should test queryFn with tags and exclude', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - query: 'tags test', - tags: ['tag1', 'tag2'], - exclude: ['excluded1', 'excluded2'], - }) - - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) - expect(response).toBeDefined() - } - }) - - it('should test useMarketplacePluginsByCollectionId queryFn coverage', async () => { - // Mock useQuery to capture queryFn from useMarketplacePluginsByCollectionId - const { useMarketplacePluginsByCollectionId } = await import('./hooks') - - // Test with undefined collectionId - should return empty array in queryFn - const { result: result1 } = renderHook(() => useMarketplacePluginsByCollectionId(undefined)) - expect(result1.current.plugins).toBeDefined() - - // Test with valid collectionId - should call API in queryFn - const { result: result2 } = renderHook(() => - useMarketplacePluginsByCollectionId('test-collection', { category: 'tool' })) - expect(result2.current).toBeDefined() - }) - - it('should test postMarketplace response with bundles', async () => { - // Temporarily modify mock response to return bundles - const originalBundles = [...mockPostMarketplaceResponse.data.bundles] - const originalPlugins = [...mockPostMarketplaceResponse.data.plugins] - mockPostMarketplaceResponse.data.bundles = [ - { type: 'bundle', org: 'test', name: 'bundle1', tags: [] }, - ] - mockPostMarketplaceResponse.data.plugins = [] - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - type: 'bundle', - query: 'test bundles', - }) - - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) - expect(response).toBeDefined() - } - - // Restore original response - mockPostMarketplaceResponse.data.bundles = originalBundles - mockPostMarketplaceResponse.data.plugins = originalPlugins - }) - - it('should cover map callback with plugins data', async () => { - // Ensure API returns plugins - mockPostMarketplaceShouldFail = false - mockPostMarketplaceResponse.data.plugins = [ - { type: 'plugin', org: 'test', name: 'plugin-for-map-1', tags: [] }, - { type: 'plugin', org: 'test', name: 'plugin-for-map-2', tags: [] }, - ] - mockPostMarketplaceResponse.data.total = 2 - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Call queryPlugins to set queryParams (which triggers queryFn in our mock) - act(() => { - result.current.queryPlugins({ - query: 'map coverage test', - category: 'tool', - }) - }) - - // The queryFn is called by our mock when enabled is true - // Since we set queryParams, enabled should be true, and queryFn should be called - // with proper params, triggering the map callback - expect(result.current.queryPlugins).toBeDefined() - }) - - it('should test queryFn return structure', async () => { - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ - query: 'structure test', - page_size: 20, - }) - - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 3, signal: controller.signal }) as { - plugins: unknown[] - total: number - page: number - page_size: number - } - - // Verify the returned structure - expect(response).toHaveProperty('plugins') - expect(response).toHaveProperty('total') - expect(response).toHaveProperty('page') - expect(response).toHaveProperty('page_size') - } - }) -}) - -// ================================ -// Line 198 flatMap Coverage Test -// ================================ -describe('flatMap Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPostMarketplaceShouldFail = false - }) - - it('should cover flatMap operation when data.pages exists', async () => { - // Set mock data with pages that have plugins - mockInfiniteQueryData = { - pages: [ - { - plugins: [ - { name: 'plugin1', type: 'plugin', org: 'test' }, - { name: 'plugin2', type: 'plugin', org: 'test' }, - ], - total: 5, - page: 1, - page_size: 40, - }, - { - plugins: [ - { name: 'plugin3', type: 'plugin', org: 'test' }, - ], - total: 5, - page: 2, - page_size: 40, - }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query to set queryParams (hasQuery = true) - result.current.queryPlugins({ - query: 'flatmap test', - }) - - // Hook should be defined - expect(result.current).toBeDefined() - // Query function should be triggered (coverage is the goal here) - expect(result.current.queryPlugins).toBeDefined() - }) - - it('should return undefined plugins when no query params', async () => { - mockInfiniteQueryData = undefined - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Don't trigger query, so hasQuery = false - expect(result.current.plugins).toBeUndefined() - }) - - it('should test hook with pages data for flatMap path', async () => { - mockInfiniteQueryData = { - pages: [ - { plugins: [], total: 100, page: 1, page_size: 40 }, - { plugins: [], total: 100, page: 2, page_size: 40 }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - result.current.queryPlugins({ query: 'total test' }) - - // Verify hook returns expected structure - expect(result.current.page).toBe(2) // pages.length - expect(result.current.queryPlugins).toBeDefined() - }) - - it('should handle API error and cover catch block', async () => { - mockPostMarketplaceShouldFail = true - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Trigger query that will fail - result.current.queryPlugins({ - query: 'error test', - category: 'tool', - }) - - // Wait for queryFn to execute and handle error - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - try { - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) as { - plugins: unknown[] - total: number - page: number - page_size: number - } - // When error is caught, should return fallback data - expect(response.plugins).toEqual([]) - expect(response.total).toBe(0) - } - catch { - // This is expected when API fails - } - } - - mockPostMarketplaceShouldFail = false - }) - - it('should test getNextPageParam directly', async () => { - const { useMarketplacePlugins } = await import('./hooks') - renderHook(() => useMarketplacePlugins()) - - // Test getNextPageParam function directly - if (capturedGetNextPageParam) { - // When there are more pages - const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 }) - expect(nextPage).toBe(2) - - // When all data is loaded - const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 }) - expect(noMorePages).toBeUndefined() - - // Edge case: exactly at boundary - const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 }) - expect(atBoundary).toBeUndefined() - } - }) - - it('should cover catch block by simulating API failure', async () => { - // Enable API failure mode - mockPostMarketplaceShouldFail = true - - const { useMarketplacePlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplacePlugins()) - - // Set params to trigger the query - act(() => { - result.current.queryPlugins({ - query: 'catch block test', - type: 'plugin', - }) - }) - - // Directly invoke queryFn to trigger the catch block - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) as { - plugins: unknown[] - total: number - page: number - page_size: number - } - // Catch block should return fallback values - expect(response.plugins).toEqual([]) - expect(response.total).toBe(0) - expect(response.page).toBe(1) - } - - mockPostMarketplaceShouldFail = false - }) - - it('should cover flatMap when hasQuery and hasData are both true', async () => { - // Set mock data before rendering - mockInfiniteQueryData = { - pages: [ - { - plugins: [{ name: 'test-plugin-1' }, { name: 'test-plugin-2' }], - total: 10, - page: 1, - page_size: 40, - }, - ], - } - - const { useMarketplacePlugins } = await import('./hooks') - const { result, rerender } = renderHook(() => useMarketplacePlugins()) - - // Trigger query to set queryParams - act(() => { - result.current.queryPlugins({ - query: 'flatmap coverage test', - }) - }) - - // Force rerender to pick up state changes - rerender() - - // After rerender, hasQuery should be true - // The hook should compute plugins from pages.flatMap - expect(result.current).toBeDefined() - }) -}) - -// ================================ -// Async Utils Tests -// ================================ - -// Narrow mock surface and avoid any in tests -// Types are local to this spec to keep scope minimal - -type FnMock = ReturnType<typeof vi.fn> - -type MarketplaceClientMock = { - collectionPlugins: FnMock - collections: FnMock -} - -describe('Async Utils', () => { - let marketplaceClientMock: MarketplaceClientMock - - beforeAll(async () => { - const mod = await import('@/service/client') - marketplaceClientMock = mod.marketplaceClient as unknown as MarketplaceClientMock - }) - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - globalThis.fetch = originalFetch - }) - - describe('getMarketplacePluginsByCollectionId', () => { - it('should fetch plugins by collection id successfully', async () => { - const mockPlugins = [ - { type: 'plugin', org: 'test', name: 'plugin1' }, - { type: 'plugin', org: 'test', name: 'plugin2' }, - ] - - // Adjusted to our mocked marketplaceClient instead of fetch - marketplaceClientMock.collectionPlugins.mockResolvedValueOnce({ - data: { plugins: mockPlugins }, - }) - - const { getMarketplacePluginsByCollectionId } = await import('./utils') - const result = await getMarketplacePluginsByCollectionId('test-collection', { - category: 'tool', - exclude: ['excluded-plugin'], - type: 'plugin', - }) - - expect(marketplaceClientMock.collectionPlugins).toHaveBeenCalled() - expect(result).toHaveLength(2) - }) - - it('should handle fetch error and return empty array', async () => { - // Simulate error from client - marketplaceClientMock.collectionPlugins.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: 'plugins', org: 'test', name: 'plugin1' }] - // Our client mock receives the signal as second arg - marketplaceClientMock.collectionPlugins.mockResolvedValueOnce({ - data: { plugins: mockPlugins }, - }) - - const controller = new AbortController() - const { getMarketplacePluginsByCollectionId } = await import('./utils') - await getMarketplacePluginsByCollectionId('test-collection', {}, { signal: controller.signal }) - - expect(marketplaceClientMock.collectionPlugins).toHaveBeenCalled() - const call = marketplaceClientMock.collectionPlugins.mock.calls[0] - expect(call[1]).toMatchObject({ signal: controller.signal }) - }) - }) - - describe('getMarketplaceCollectionsAndPlugins', () => { - it('should fetch collections and plugins successfully', async () => { - const mockCollections = [ - { name: 'collection1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, - ] - const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }] - - // Simulate two-step client calls: collections then collectionPlugins - let stage = 0 - marketplaceClientMock.collections.mockImplementationOnce(async () => { - stage = 1 - return { data: { collections: mockCollections } } - }) - marketplaceClientMock.collectionPlugins.mockImplementation(async () => { - if (stage === 1) { - return { data: { plugins: mockPlugins } } - } - return { data: { plugins: [] } } - }) - - 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 () => { - // Simulate client error - marketplaceClientMock.collections.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 () => { - // Assert that the client was called with query containing condition/type - const { getMarketplaceCollectionsAndPlugins } = await import('./utils') - await getMarketplaceCollectionsAndPlugins({ - condition: 'category=tool', - type: 'bundle', - }) - - expect(marketplaceClientMock.collections).toHaveBeenCalled() - const call = marketplaceClientMock.collections.mock.calls[0] - expect(call[0]).toMatchObject({ query: expect.objectContaining({ condition: 'category=tool', type: 'bundle' }) }) - }) - }) -}) - -// ================================ -// 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' - 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') - 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-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-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' - document.body.appendChild(mockContainer) - - const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener') - const { useMarketplaceContainerScroll } = await import('./hooks') - - const TestComponent = () => { - useMarketplaceContainerScroll(mockCallback, 'scroll-unmount-container') - return null - } - - const { unmount } = render(<TestComponent />) - unmount() - - expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)) - document.body.removeChild(mockContainer) - }) -}) - -// ================================ -// Test Data Factory Tests -// ================================ -describe('Test Data Factories', () => { - describe('createMockPlugin', () => { - it('should create plugin with default values', () => { - const plugin = createMockPlugin() - - expect(plugin.type).toBe('plugin') - expect(plugin.org).toBe('test-org') - expect(plugin.version).toBe('1.0.0') - expect(plugin.verified).toBe(true) - expect(plugin.category).toBe(PluginCategoryEnum.tool) - expect(plugin.install_count).toBe(1000) - }) - - it('should allow overriding default values', () => { - const plugin = createMockPlugin({ - name: 'custom-plugin', - org: 'custom-org', - version: '2.0.0', - install_count: 5000, - }) - - expect(plugin.name).toBe('custom-plugin') - expect(plugin.org).toBe('custom-org') - expect(plugin.version).toBe('2.0.0') - expect(plugin.install_count).toBe(5000) - }) - - it('should create bundle type plugin', () => { - const bundle = createMockPlugin({ type: 'bundle' }) - - expect(bundle.type).toBe('bundle') - }) - }) - - describe('createMockPluginList', () => { - it('should create correct number of plugins', () => { - const plugins = createMockPluginList(5) - - expect(plugins).toHaveLength(5) - }) - - it('should create plugins with unique names', () => { - const plugins = createMockPluginList(3) - const names = plugins.map(p => p.name) - - expect(new Set(names).size).toBe(3) - }) - - it('should create plugins with decreasing install counts', () => { - const plugins = createMockPluginList(3) - - expect(plugins[0].install_count).toBeGreaterThan(plugins[1].install_count) - expect(plugins[1].install_count).toBeGreaterThan(plugins[2].install_count) - }) - }) - - describe('createMockCollection', () => { - it('should create collection with default values', () => { - const collection = createMockCollection() - - expect(collection.name).toBe('test-collection') - expect(collection.label['en-US']).toBe('Test Collection') - expect(collection.searchable).toBe(true) - }) - - it('should allow overriding default values', () => { - const collection = createMockCollection({ - name: 'custom-collection', - searchable: false, - }) - - expect(collection.name).toBe('custom-collection') - expect(collection.searchable).toBe(false) - }) - }) -}) diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/marketplace/list/index.spec.tsx rename to web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx index 31419030a4..7f88cf366c 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx @@ -1,17 +1,16 @@ -import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' +import type { MarketplaceCollection, SearchParamsFromCollection } from '../../types' import type { Plugin } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' -import List from './index' -import ListWithCollection from './list-with-collection' -import ListWrapper from './list-wrapper' +import List from '../index' +import ListWithCollection from '../list-with-collection' +import ListWrapper from '../list-wrapper' // ================================ // Mock External Dependencies Only // ================================ -// Mock i18n translation hook vi.mock('#i18n', () => ({ useTranslation: () => ({ t: (key: string, options?: { ns?: string, num?: number }) => { @@ -30,7 +29,6 @@ vi.mock('#i18n', () => ({ useLocale: () => 'en-US', })) -// Mock marketplace state hooks with controllable values const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { return { mockMarketplaceData: { @@ -45,27 +43,18 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { } }) -vi.mock('../state', () => ({ +vi.mock('../../state', () => ({ useMarketplaceData: () => mockMarketplaceData, })) -vi.mock('../atoms', () => ({ +vi.mock('../../atoms', () => ({ useMarketplaceMoreClick: () => mockMoreClick, })) -// Mock useLocale context vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', })) -// Mock next-themes -vi.mock('next-themes', () => ({ - useTheme: () => ({ - theme: 'light', - }), -})) - -// Mock useTags hook const mockTags = [ { name: 'search', label: 'Search' }, { name: 'image', label: 'Image' }, @@ -85,7 +74,6 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) -// Mock ahooks useBoolean with controllable state let mockUseBooleanValue = false const mockSetTrue = vi.fn(() => { mockUseBooleanValue = true @@ -107,20 +95,17 @@ vi.mock('ahooks', () => ({ }, })) -// Mock i18n-config/language vi.mock('@/i18n-config/language', () => ({ getLanguage: (locale: string) => locale || 'en-US', })) -// Mock marketplace utils -vi.mock('../utils', () => ({ +vi.mock('../../utils', () => ({ getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) => `/plugins/${plugin.org}/${plugin.name}`, getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/plugins/${plugin.org}/${plugin.name}`, })) -// Mock Card component vi.mock('@/app/components/plugins/card', () => ({ default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => ( <div data-testid={`card-${payload.name}`}> @@ -131,7 +116,6 @@ vi.mock('@/app/components/plugins/card', () => ({ ), })) -// Mock CardMoreInfo component vi.mock('@/app/components/plugins/card/card-more-info', () => ({ default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => ( <div data-testid="card-more-info"> @@ -141,7 +125,6 @@ vi.mock('@/app/components/plugins/card/card-more-info', () => ({ ), })) -// Mock InstallFromMarketplace component vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="install-from-marketplace"> @@ -150,15 +133,13 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () = ), })) -// Mock SortDropdown component -vi.mock('../sort-dropdown', () => ({ +vi.mock('../../sort-dropdown', () => ({ default: () => ( <div data-testid="sort-dropdown">Sort</div> ), })) -// Mock Empty component -vi.mock('../empty', () => ({ +vi.mock('../../empty', () => ({ default: ({ className }: { className?: string }) => ( <div data-testid="empty-component" className={className}> No plugins found @@ -166,7 +147,6 @@ vi.mock('../empty', () => ({ ), })) -// Mock Loading component vi.mock('@/app/components/base/loading', () => ({ default: () => <div data-testid="loading-component">Loading...</div>, })) diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/marketplace/search-box/index.spec.tsx rename to web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx index 85be82cb33..e3c7450a39 100644 --- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ import type { Tag } from '@/app/components/plugins/hooks' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import SearchBox from './index' -import SearchBoxWrapper from './search-box-wrapper' -import MarketplaceTrigger from './trigger/marketplace' -import ToolSelectorTrigger from './trigger/tool-selector' +import SearchBox from '../index' +import SearchBoxWrapper from '../search-box-wrapper' +import MarketplaceTrigger from '../trigger/marketplace' +import ToolSelectorTrigger from '../trigger/tool-selector' // ================================ // Mock external dependencies only @@ -36,7 +36,7 @@ const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPlugin } }) -vi.mock('../atoms', () => ({ +vi.mock('../../atoms', () => ({ useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange], useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], })) diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx rename to web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx index f91c7ba4d3..664f8520b2 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import SortDropdown from './index' +import SortDropdown from '../index' // ================================ // Mock external dependencies only @@ -31,7 +31,7 @@ vi.mock('#i18n', () => ({ let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' } const mockHandleSortChange = vi.fn() -vi.mock('../atoms', () => ({ +vi.mock('../../atoms', () => ({ useMarketplaceSort: () => [mockSort, mockHandleSortChange], })) @@ -39,7 +39,7 @@ vi.mock('../atoms', () => ({ let mockPortalOpenState = false vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open, onOpenChange }: { + PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: { children: React.ReactNode open: boolean onOpenChange: (open: boolean) => void diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx new file mode 100644 index 0000000000..d5cea7a495 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx @@ -0,0 +1,45 @@ +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('@/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('plugin.auth.authorization')).toBeInTheDocument() + }) + + it('renders plural text for multiple authorizations', () => { + render(<AuthorizedInDataSourceNode authorizationsNum={3} onJumpToDataSourcePage={mockOnJump} />) + expect(screen.getByText('plugin.auth.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 settings button', () => { + render(<AuthorizedInDataSourceNode authorizationsNum={1} onJumpToDataSourcePage={mockOnJump} />) + expect(screen.getByRole('button')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx new file mode 100644 index 0000000000..7e8208b995 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx @@ -0,0 +1,210 @@ +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') + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx new file mode 100644 index 0000000000..16b5eb580d --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx @@ -0,0 +1,247 @@ +import type { ReactNode } from 'react' +import type { Credential, PluginPayload } from '../types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { describe, expect, it, vi } from 'vitest' +import { AuthCategory, CredentialTypeEnum } from '../types' + +const mockGetPluginCredentialInfo = vi.fn() +const mockDeletePluginCredential = vi.fn() +const mockSetPluginDefaultCredential = vi.fn() +const mockUpdatePluginCredential = vi.fn() +const mockInvalidPluginCredentialInfo = vi.fn() +const mockGetPluginOAuthUrl = vi.fn() +const mockGetPluginOAuthClientSchema = vi.fn() +const mockSetPluginOAuthCustomClient = vi.fn() +const mockDeletePluginOAuthCustomClient = vi.fn() +const mockInvalidPluginOAuthClientSchema = vi.fn() +const mockAddPluginCredential = vi.fn() +const mockGetPluginCredentialSchema = vi.fn() +const mockInvalidToolsByType = vi.fn() + +vi.mock('@/service/use-plugins-auth', () => ({ + useGetPluginCredentialInfo: (url: string) => ({ + data: url ? mockGetPluginCredentialInfo() : undefined, + isLoading: false, + }), + useDeletePluginCredential: () => ({ + mutateAsync: mockDeletePluginCredential, + }), + useSetPluginDefaultCredential: () => ({ + mutateAsync: mockSetPluginDefaultCredential, + }), + useUpdatePluginCredential: () => ({ + mutateAsync: mockUpdatePluginCredential, + }), + useInvalidPluginCredentialInfo: () => mockInvalidPluginCredentialInfo, + useGetPluginOAuthUrl: () => ({ + mutateAsync: mockGetPluginOAuthUrl, + }), + useGetPluginOAuthClientSchema: () => ({ + data: mockGetPluginOAuthClientSchema(), + isLoading: false, + }), + useSetPluginOAuthCustomClient: () => ({ + mutateAsync: mockSetPluginOAuthCustomClient, + }), + useDeletePluginOAuthCustomClient: () => ({ + mutateAsync: mockDeletePluginOAuthCustomClient, + }), + useInvalidPluginOAuthClientSchema: () => mockInvalidPluginOAuthClientSchema, + useAddPluginCredential: () => ({ + mutateAsync: mockAddPluginCredential, + }), + useGetPluginCredentialSchema: () => ({ + data: mockGetPluginCredentialSchema(), + isLoading: false, + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useInvalidToolsByType: () => mockInvalidToolsByType, +})) + +const mockIsCurrentWorkspaceManager = vi.fn() +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), + }), +})) + +const mockNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +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(), +})) + +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, +}) + +const _createCredentialList = (count: number, overrides: Partial<Credential>[] = []): Credential[] => { + return Array.from({ length: count }, (_, i) => createCredential({ + id: `credential-${i}`, + name: `Credential ${i}`, + is_default: i === 0, + ...overrides[i], + })) +} + +describe('Index Exports', () => { + it('should export all required components and hooks', async () => { + const exports = await import('../index') + + expect(exports.AddApiKeyButton).toBeDefined() + expect(exports.AddOAuthButton).toBeDefined() + expect(exports.ApiKeyModal).toBeDefined() + expect(exports.Authorized).toBeDefined() + expect(exports.AuthorizedInDataSourceNode).toBeDefined() + expect(exports.AuthorizedInNode).toBeDefined() + expect(exports.usePluginAuth).toBeDefined() + expect(exports.PluginAuth).toBeDefined() + expect(exports.PluginAuthInAgent).toBeDefined() + expect(exports.PluginAuthInDataSourceNode).toBeDefined() + }, 15000) + + it('should export AuthCategory enum', async () => { + const exports = await import('../index') + + expect(exports.AuthCategory).toBeDefined() + expect(exports.AuthCategory.tool).toBe('tool') + expect(exports.AuthCategory.datasource).toBe('datasource') + expect(exports.AuthCategory.model).toBe('model') + expect(exports.AuthCategory.trigger).toBe('trigger') + }, 15000) + + it('should export CredentialTypeEnum', async () => { + const exports = await import('../index') + + expect(exports.CredentialTypeEnum).toBeDefined() + expect(exports.CredentialTypeEnum.OAUTH2).toBe('oauth2') + expect(exports.CredentialTypeEnum.API_KEY).toBe('api-key') + }, 15000) +}) + +describe('Types', () => { + describe('AuthCategory enum', () => { + it('should have correct values', () => { + expect(AuthCategory.tool).toBe('tool') + expect(AuthCategory.datasource).toBe('datasource') + expect(AuthCategory.model).toBe('model') + expect(AuthCategory.trigger).toBe('trigger') + }) + + it('should have exactly 4 categories', () => { + const values = Object.values(AuthCategory) + expect(values).toHaveLength(4) + }) + }) + + describe('CredentialTypeEnum', () => { + it('should have correct values', () => { + expect(CredentialTypeEnum.OAUTH2).toBe('oauth2') + expect(CredentialTypeEnum.API_KEY).toBe('api-key') + }) + + it('should have exactly 2 types', () => { + const values = Object.values(CredentialTypeEnum) + expect(values).toHaveLength(2) + }) + }) + + describe('Credential type', () => { + it('should allow creating valid credentials', () => { + const credential: Credential = { + id: 'test-id', + name: 'Test', + provider: 'test-provider', + is_default: true, + } + expect(credential.id).toBe('test-id') + expect(credential.is_default).toBe(true) + }) + + it('should allow optional fields', () => { + const credential: Credential = { + id: 'test-id', + name: 'Test', + provider: 'test-provider', + is_default: false, + credential_type: CredentialTypeEnum.API_KEY, + credentials: { key: 'value' }, + isWorkspaceDefault: true, + from_enterprise: false, + not_allowed_to_use: false, + } + expect(credential.credential_type).toBe(CredentialTypeEnum.API_KEY) + expect(credential.isWorkspaceDefault).toBe(true) + }) + }) + + describe('PluginPayload type', () => { + it('should allow creating valid plugin payload', () => { + const payload: PluginPayload = { + category: AuthCategory.tool, + provider: 'test-provider', + } + expect(payload.category).toBe(AuthCategory.tool) + }) + + it('should allow optional fields', () => { + const payload: PluginPayload = { + category: AuthCategory.datasource, + provider: 'test-provider', + providerType: 'builtin', + detail: undefined, + } + expect(payload.providerType).toBe('builtin') + }) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx new file mode 100644 index 0000000000..6b66aca9dd --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx @@ -0,0 +1,255 @@ +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') + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx new file mode 100644 index 0000000000..4fd899af4f --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-datasource-node.spec.tsx @@ -0,0 +1,51 @@ +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' + +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('common.integrations.connect')).toBeInTheDocument() + }) + + it('renders connect button', () => { + render(<PluginAuthInDataSourceNode onJumpToDataSourcePage={mockOnJump} />) + expect(screen.getByRole('button', { name: /common\.integrations\.connect/ })).toBeInTheDocument() + }) + + it('calls onJumpToDataSourcePage when connect button is clicked', () => { + render(<PluginAuthInDataSourceNode onJumpToDataSourcePage={mockOnJump} />) + fireEvent.click(screen.getByRole('button', { name: /common\.integrations\.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('common.integrations.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('common.integrations.connect')).toBeInTheDocument() + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx new file mode 100644 index 0000000000..511f3a25a3 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx @@ -0,0 +1,139 @@ +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) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/utils.spec.ts b/web/app/components/plugins/plugin-auth/__tests__/utils.spec.ts new file mode 100644 index 0000000000..878f3111ab --- /dev/null +++ b/web/app/components/plugins/plugin-auth/__tests__/utils.spec.ts @@ -0,0 +1,55 @@ +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) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx new file mode 100644 index 0000000000..794f847168 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx @@ -0,0 +1,67 @@ +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() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx new file mode 100644 index 0000000000..46d57a8ab3 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx @@ -0,0 +1,102 @@ +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('@/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(' '), +})) + +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() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx new file mode 100644 index 0000000000..a99b3363d6 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx @@ -0,0 +1,165 @@ +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('@/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('plugin.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() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx rename to web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx index f2a80ead3c..51aa287fea 100644 --- a/web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' -import type { PluginPayload } from '../types' +import type { PluginPayload } from '../../types' import type { FormSchema } from '@/app/components/base/form/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AuthCategory } from '../types' +import { AuthCategory } from '../../types' // Create a wrapper with QueryClientProvider const createTestQueryClient = () => @@ -36,7 +36,7 @@ const mockAddPluginCredential = vi.fn() const mockUpdatePluginCredential = vi.fn() const mockGetPluginCredentialSchema = vi.fn() -vi.mock('../hooks/use-credential', () => ({ +vi.mock('../../hooks/use-credential', () => ({ useGetPluginOAuthUrlHook: () => ({ mutateAsync: mockGetPluginOAuthUrl, }), @@ -117,12 +117,12 @@ const createFormSchema = (overrides: Partial<FormSchema> = {}): FormSchema => ({ // ==================== AddApiKeyButton Tests ==================== describe('AddApiKeyButton', () => { - let AddApiKeyButton: typeof import('./add-api-key-button').default + let AddApiKeyButton: typeof import('../add-api-key-button').default beforeEach(async () => { vi.clearAllMocks() mockGetPluginCredentialSchema.mockReturnValue([]) - const importedAddApiKeyButton = await import('./add-api-key-button') + const importedAddApiKeyButton = await import('../add-api-key-button') AddApiKeyButton = importedAddApiKeyButton.default }) @@ -327,7 +327,7 @@ describe('AddApiKeyButton', () => { describe('Memoization', () => { it('should be a memoized component', async () => { - const AddApiKeyButtonDefault = (await import('./add-api-key-button')).default + const AddApiKeyButtonDefault = (await import('../add-api-key-button')).default expect(typeof AddApiKeyButtonDefault).toBe('object') }) }) @@ -335,7 +335,7 @@ describe('AddApiKeyButton', () => { // ==================== AddOAuthButton Tests ==================== describe('AddOAuthButton', () => { - let AddOAuthButton: typeof import('./add-oauth-button').default + let AddOAuthButton: typeof import('../add-oauth-button').default beforeEach(async () => { vi.clearAllMocks() @@ -347,7 +347,7 @@ describe('AddOAuthButton', () => { redirect_uri: 'https://example.com/callback', }) mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' }) - const importedAddOAuthButton = await import('./add-oauth-button') + const importedAddOAuthButton = await import('../add-oauth-button') AddOAuthButton = importedAddOAuthButton.default }) @@ -856,7 +856,7 @@ describe('AddOAuthButton', () => { // ==================== ApiKeyModal Tests ==================== describe('ApiKeyModal', () => { - let ApiKeyModal: typeof import('./api-key-modal').default + let ApiKeyModal: typeof import('../api-key-modal').default beforeEach(async () => { vi.clearAllMocks() @@ -870,7 +870,7 @@ describe('ApiKeyModal', () => { isCheckValidated: false, values: {}, }) - const importedApiKeyModal = await import('./api-key-modal') + const importedApiKeyModal = await import('../api-key-modal') ApiKeyModal = importedApiKeyModal.default }) @@ -1272,13 +1272,13 @@ describe('ApiKeyModal', () => { // ==================== OAuthClientSettings Tests ==================== describe('OAuthClientSettings', () => { - let OAuthClientSettings: typeof import('./oauth-client-settings').default + let OAuthClientSettings: typeof import('../oauth-client-settings').default beforeEach(async () => { vi.clearAllMocks() mockSetPluginOAuthCustomClient.mockResolvedValue({}) mockDeletePluginOAuthCustomClient.mockResolvedValue({}) - const importedOAuthClientSettings = await import('./oauth-client-settings') + const importedOAuthClientSettings = await import('../oauth-client-settings') OAuthClientSettings = importedOAuthClientSettings.default }) @@ -2193,7 +2193,7 @@ describe('OAuthClientSettings', () => { describe('Memoization', () => { it('should be a memoized component', async () => { - const OAuthClientSettingsDefault = (await import('./oauth-client-settings')).default + const OAuthClientSettingsDefault = (await import('../oauth-client-settings')).default expect(typeof OAuthClientSettingsDefault).toBe('object') }) }) @@ -2216,7 +2216,7 @@ describe('Authorize Components Integration', () => { describe('AddApiKeyButton -> ApiKeyModal Flow', () => { it('should open ApiKeyModal when AddApiKeyButton is clicked', async () => { - const AddApiKeyButton = (await import('./add-api-key-button')).default + const AddApiKeyButton = (await import('../add-api-key-button')).default const pluginPayload = createPluginPayload() render(<AddApiKeyButton pluginPayload={pluginPayload} />, { wrapper: createWrapper() }) @@ -2231,7 +2231,7 @@ describe('Authorize Components Integration', () => { describe('AddOAuthButton -> OAuthClientSettings Flow', () => { it('should open OAuthClientSettings when setup button is clicked', async () => { - const AddOAuthButton = (await import('./add-oauth-button')).default + const AddOAuthButton = (await import('../add-oauth-button')).default const pluginPayload = createPluginPayload() mockGetPluginOAuthClientSchema.mockReturnValue({ schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], diff --git a/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-auth/authorize/index.spec.tsx rename to web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx index 354ef8eeea..fb7eb4bd12 100644 --- a/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' -import type { PluginPayload } from '../types' +import type { PluginPayload } from '../../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AuthCategory } from '../types' -import Authorize from './index' +import { AuthCategory } from '../../types' +import Authorize from '../index' // Create a wrapper with QueryClientProvider for real component testing const createTestQueryClient = () => @@ -29,7 +29,7 @@ const createWrapper = () => { // Mock API hooks - only mock network-related hooks const mockGetPluginOAuthClientSchema = vi.fn() -vi.mock('../hooks/use-credential', () => ({ +vi.mock('../../hooks/use-credential', () => ({ useGetPluginOAuthUrlHook: () => ({ mutateAsync: vi.fn().mockResolvedValue({ authorization_url: '' }), }), @@ -568,7 +568,7 @@ describe('Authorize', () => { // ==================== Component Memoization ==================== describe('Component Memoization', () => { it('should be a memoized component (exported with memo)', async () => { - const AuthorizeDefault = (await import('./index')).default + const AuthorizeDefault = (await import('../index')).default expect(AuthorizeDefault).toBeDefined() // memo wrapped components are React elements with $$typeof expect(typeof AuthorizeDefault).toBe('object') diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx new file mode 100644 index 0000000000..61920e2869 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx @@ -0,0 +1,179 @@ +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('@/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('plugin.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() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorized/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-auth/authorized/index.spec.tsx rename to web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx index 6d6fbf7cb4..f56c814222 100644 --- a/web/app/components/plugins/plugin-auth/authorized/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' -import type { Credential, PluginPayload } from '../types' +import type { Credential, PluginPayload } from '../../types' 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 { AuthCategory, CredentialTypeEnum } from '../types' -import Authorized from './index' +import { AuthCategory, CredentialTypeEnum } from '../../types' +import Authorized from '../index' // ==================== Mock Setup ==================== @@ -13,7 +13,7 @@ const mockDeletePluginCredential = vi.fn() const mockSetPluginDefaultCredential = vi.fn() const mockUpdatePluginCredential = vi.fn() -vi.mock('../hooks/use-credential', () => ({ +vi.mock('../../hooks/use-credential', () => ({ useDeletePluginCredentialHook: () => ({ mutateAsync: mockDeletePluginCredential, }), @@ -1620,7 +1620,7 @@ describe('Authorized Component', () => { // ==================== Memoization Test ==================== describe('Memoization', () => { it('should be memoized', async () => { - const AuthorizedModule = await import('./index') + const AuthorizedModule = await import('../index') // memo returns an object with $$typeof expect(typeof AuthorizedModule.default).toBe('object') }) diff --git a/web/app/components/plugins/plugin-auth/authorized/item.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-auth/authorized/item.spec.tsx rename to web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx index 7ea82010b1..156b20b7d9 100644 --- a/web/app/components/plugins/plugin-auth/authorized/item.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx @@ -1,8 +1,8 @@ -import type { Credential } from '../types' +import type { Credential } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { CredentialTypeEnum } from '../types' -import Item from './item' +import { CredentialTypeEnum } from '../../types' +import Item from '../item' // ==================== Test Utilities ==================== @@ -829,7 +829,7 @@ describe('Item Component', () => { // ==================== Memoization Test ==================== describe('Memoization', () => { it('should be memoized', async () => { - const ItemModule = await import('./item') + const ItemModule = await import('../item') // memo returns an object with $$typeof expect(typeof ItemModule.default).toBe('object') }) diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts new file mode 100644 index 0000000000..7777fbff97 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts @@ -0,0 +1,186 @@ +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`, + ) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts new file mode 100644 index 0000000000..6b1063dce5 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-get-api.spec.ts @@ -0,0 +1,80 @@ +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') + }) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts new file mode 100644 index 0000000000..d31b29ab85 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth-action.spec.ts @@ -0,0 +1,191 @@ +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('@/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() + }) +}) diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth.spec.ts new file mode 100644 index 0000000000..2903eb8c34 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-plugin-auth.spec.ts @@ -0,0 +1,110 @@ +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) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/index.spec.tsx b/web/app/components/plugins/plugin-auth/index.spec.tsx deleted file mode 100644 index 328de71e8d..0000000000 --- a/web/app/components/plugins/plugin-auth/index.spec.tsx +++ /dev/null @@ -1,2035 +0,0 @@ -import type { ReactNode } from 'react' -import type { Credential, PluginPayload } from './types' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AuthCategory, CredentialTypeEnum } from './types' - -// ==================== Mock Setup ==================== - -// Mock API hooks for credential operations -const mockGetPluginCredentialInfo = vi.fn() -const mockDeletePluginCredential = vi.fn() -const mockSetPluginDefaultCredential = vi.fn() -const mockUpdatePluginCredential = vi.fn() -const mockInvalidPluginCredentialInfo = vi.fn() -const mockGetPluginOAuthUrl = vi.fn() -const mockGetPluginOAuthClientSchema = vi.fn() -const mockSetPluginOAuthCustomClient = vi.fn() -const mockDeletePluginOAuthCustomClient = vi.fn() -const mockInvalidPluginOAuthClientSchema = vi.fn() -const mockAddPluginCredential = vi.fn() -const mockGetPluginCredentialSchema = vi.fn() -const mockInvalidToolsByType = vi.fn() - -vi.mock('@/service/use-plugins-auth', () => ({ - useGetPluginCredentialInfo: (url: string) => ({ - data: url ? mockGetPluginCredentialInfo() : undefined, - isLoading: false, - }), - useDeletePluginCredential: () => ({ - mutateAsync: mockDeletePluginCredential, - }), - useSetPluginDefaultCredential: () => ({ - mutateAsync: mockSetPluginDefaultCredential, - }), - useUpdatePluginCredential: () => ({ - mutateAsync: mockUpdatePluginCredential, - }), - useInvalidPluginCredentialInfo: () => mockInvalidPluginCredentialInfo, - useGetPluginOAuthUrl: () => ({ - mutateAsync: mockGetPluginOAuthUrl, - }), - useGetPluginOAuthClientSchema: () => ({ - data: mockGetPluginOAuthClientSchema(), - isLoading: false, - }), - useSetPluginOAuthCustomClient: () => ({ - mutateAsync: mockSetPluginOAuthCustomClient, - }), - useDeletePluginOAuthCustomClient: () => ({ - mutateAsync: mockDeletePluginOAuthCustomClient, - }), - useInvalidPluginOAuthClientSchema: () => mockInvalidPluginOAuthClientSchema, - useAddPluginCredential: () => ({ - mutateAsync: mockAddPluginCredential, - }), - useGetPluginCredentialSchema: () => ({ - data: mockGetPluginCredentialSchema(), - isLoading: false, - }), -})) - -vi.mock('@/service/use-tools', () => ({ - useInvalidToolsByType: () => mockInvalidToolsByType, -})) - -// Mock AppContext -const mockIsCurrentWorkspaceManager = vi.fn() -vi.mock('@/context/app-context', () => ({ - useAppContext: () => ({ - isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), - }), -})) - -// Mock toast context -const mockNotify = vi.fn() -vi.mock('@/app/components/base/toast', () => ({ - useToastContext: () => ({ - notify: mockNotify, - }), -})) - -// Mock openOAuthPopup -vi.mock('@/hooks/use-oauth', () => ({ - openOAuthPopup: vi.fn(), -})) - -// Mock service/use-triggers -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> - ) -} - -// Factory functions for test data -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, -}) - -const createCredentialList = (count: number, overrides: Partial<Credential>[] = []): Credential[] => { - return Array.from({ length: count }, (_, i) => createCredential({ - id: `credential-${i}`, - name: `Credential ${i}`, - is_default: i === 0, - ...overrides[i], - })) -} - -// ==================== Index Exports Tests ==================== -describe('Index Exports', () => { - it('should export all required components and hooks', async () => { - const exports = await import('./index') - - expect(exports.AddApiKeyButton).toBeDefined() - expect(exports.AddOAuthButton).toBeDefined() - expect(exports.ApiKeyModal).toBeDefined() - expect(exports.Authorized).toBeDefined() - expect(exports.AuthorizedInDataSourceNode).toBeDefined() - expect(exports.AuthorizedInNode).toBeDefined() - expect(exports.usePluginAuth).toBeDefined() - expect(exports.PluginAuth).toBeDefined() - expect(exports.PluginAuthInAgent).toBeDefined() - expect(exports.PluginAuthInDataSourceNode).toBeDefined() - }) - - it('should export AuthCategory enum', async () => { - const exports = await import('./index') - - expect(exports.AuthCategory).toBeDefined() - expect(exports.AuthCategory.tool).toBe('tool') - expect(exports.AuthCategory.datasource).toBe('datasource') - expect(exports.AuthCategory.model).toBe('model') - expect(exports.AuthCategory.trigger).toBe('trigger') - }) - - it('should export CredentialTypeEnum', async () => { - const exports = await import('./index') - - expect(exports.CredentialTypeEnum).toBeDefined() - expect(exports.CredentialTypeEnum.OAUTH2).toBe('oauth2') - expect(exports.CredentialTypeEnum.API_KEY).toBe('api-key') - }) -}) - -// ==================== Types Tests ==================== -describe('Types', () => { - describe('AuthCategory enum', () => { - it('should have correct values', () => { - expect(AuthCategory.tool).toBe('tool') - expect(AuthCategory.datasource).toBe('datasource') - expect(AuthCategory.model).toBe('model') - expect(AuthCategory.trigger).toBe('trigger') - }) - - it('should have exactly 4 categories', () => { - const values = Object.values(AuthCategory) - expect(values).toHaveLength(4) - }) - }) - - describe('CredentialTypeEnum', () => { - it('should have correct values', () => { - expect(CredentialTypeEnum.OAUTH2).toBe('oauth2') - expect(CredentialTypeEnum.API_KEY).toBe('api-key') - }) - - it('should have exactly 2 types', () => { - const values = Object.values(CredentialTypeEnum) - expect(values).toHaveLength(2) - }) - }) - - describe('Credential type', () => { - it('should allow creating valid credentials', () => { - const credential: Credential = { - id: 'test-id', - name: 'Test', - provider: 'test-provider', - is_default: true, - } - expect(credential.id).toBe('test-id') - expect(credential.is_default).toBe(true) - }) - - it('should allow optional fields', () => { - const credential: Credential = { - id: 'test-id', - name: 'Test', - provider: 'test-provider', - is_default: false, - credential_type: CredentialTypeEnum.API_KEY, - credentials: { key: 'value' }, - isWorkspaceDefault: true, - from_enterprise: false, - not_allowed_to_use: false, - } - expect(credential.credential_type).toBe(CredentialTypeEnum.API_KEY) - expect(credential.isWorkspaceDefault).toBe(true) - }) - }) - - describe('PluginPayload type', () => { - it('should allow creating valid plugin payload', () => { - const payload: PluginPayload = { - category: AuthCategory.tool, - provider: 'test-provider', - } - expect(payload.category).toBe(AuthCategory.tool) - }) - - it('should allow optional fields', () => { - const payload: PluginPayload = { - category: AuthCategory.datasource, - provider: 'test-provider', - providerType: 'builtin', - detail: undefined, - } - expect(payload.providerType).toBe('builtin') - }) - }) -}) - -// ==================== Utils Tests ==================== -describe('Utils', () => { - describe('transformFormSchemasSecretInput', () => { - it('should transform secret input values to hidden format', async () => { - const { transformFormSchemasSecretInput } = await import('./utils') - - const secretNames = ['api_key', 'secret_token'] - const values = { - api_key: 'actual-key', - secret_token: 'actual-token', - public_key: 'public-value', - } - - const result = transformFormSchemasSecretInput(secretNames, values) - - expect(result.api_key).toBe('[__HIDDEN__]') - expect(result.secret_token).toBe('[__HIDDEN__]') - expect(result.public_key).toBe('public-value') - }) - - it('should not transform empty secret values', async () => { - const { transformFormSchemasSecretInput } = await import('./utils') - - const secretNames = ['api_key'] - const values = { - api_key: '', - public_key: 'public-value', - } - - const result = transformFormSchemasSecretInput(secretNames, values) - - expect(result.api_key).toBe('') - expect(result.public_key).toBe('public-value') - }) - - it('should not transform undefined secret values', async () => { - const { transformFormSchemasSecretInput } = await import('./utils') - - const secretNames = ['api_key'] - const values = { - public_key: 'public-value', - } - - const result = transformFormSchemasSecretInput(secretNames, values) - - expect(result.api_key).toBeUndefined() - expect(result.public_key).toBe('public-value') - }) - - it('should handle empty secret names array', async () => { - const { transformFormSchemasSecretInput } = await import('./utils') - - const secretNames: string[] = [] - const values = { - api_key: 'actual-key', - public_key: 'public-value', - } - - const result = transformFormSchemasSecretInput(secretNames, values) - - expect(result.api_key).toBe('actual-key') - expect(result.public_key).toBe('public-value') - }) - - it('should handle empty values object', async () => { - const { transformFormSchemasSecretInput } = await import('./utils') - - const secretNames = ['api_key'] - const values = {} - - const result = transformFormSchemasSecretInput(secretNames, values) - - expect(Object.keys(result)).toHaveLength(0) - }) - - it('should preserve original values object immutably', async () => { - const { transformFormSchemasSecretInput } = await import('./utils') - - const secretNames = ['api_key'] - const values = { - api_key: 'actual-key', - public_key: 'public-value', - } - - transformFormSchemasSecretInput(secretNames, values) - - expect(values.api_key).toBe('actual-key') - }) - - it('should handle null-ish values correctly', async () => { - const { transformFormSchemasSecretInput } = await import('./utils') - - const secretNames = ['api_key', 'null_key'] - const values = { - api_key: null, - null_key: 0, - } - - const result = transformFormSchemasSecretInput(secretNames, values as Record<string, unknown>) - - // null is preserved as-is to represent an explicitly unset secret, not masked as [__HIDDEN__] - expect(result.api_key).toBe(null) - // numeric values like 0 are also preserved; only non-empty string secrets are transformed - expect(result.null_key).toBe(0) - }) - }) -}) - -// ==================== useGetApi Hook Tests ==================== -describe('useGetApi Hook', () => { - describe('tool category', () => { - it('should return correct API endpoints for tool category', async () => { - const { useGetApi } = await import('./hooks/use-get-api') - - const pluginPayload = createPluginPayload({ - category: AuthCategory.tool, - provider: 'test-tool', - }) - - const apiMap = useGetApi(pluginPayload) - - expect(apiMap.getCredentialInfo).toBe('/workspaces/current/tool-provider/builtin/test-tool/credential/info') - expect(apiMap.setDefaultCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/default-credential') - expect(apiMap.getCredentials).toBe('/workspaces/current/tool-provider/builtin/test-tool/credentials') - expect(apiMap.addCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/add') - expect(apiMap.updateCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/update') - expect(apiMap.deleteCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/delete') - expect(apiMap.getOauthUrl).toBe('/oauth/plugin/test-tool/tool/authorization-url') - expect(apiMap.getOauthClientSchema).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/client-schema') - expect(apiMap.setCustomOauthClient).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/custom-client') - expect(apiMap.deleteCustomOAuthClient).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/custom-client') - }) - - it('should return getCredentialSchema function for tool category', async () => { - const { useGetApi } = await import('./hooks/use-get-api') - - const pluginPayload = createPluginPayload({ - category: AuthCategory.tool, - provider: 'test-tool', - }) - - const apiMap = useGetApi(pluginPayload) - - expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe( - '/workspaces/current/tool-provider/builtin/test-tool/credential/schema/api-key', - ) - expect(apiMap.getCredentialSchema(CredentialTypeEnum.OAUTH2)).toBe( - '/workspaces/current/tool-provider/builtin/test-tool/credential/schema/oauth2', - ) - }) - }) - - describe('datasource category', () => { - it('should return correct API endpoints for datasource category', async () => { - const { useGetApi } = await import('./hooks/use-get-api') - - const pluginPayload = createPluginPayload({ - category: AuthCategory.datasource, - provider: 'test-datasource', - }) - - const apiMap = useGetApi(pluginPayload) - - expect(apiMap.getCredentialInfo).toBe('') - expect(apiMap.setDefaultCredential).toBe('/auth/plugin/datasource/test-datasource/default') - expect(apiMap.getCredentials).toBe('/auth/plugin/datasource/test-datasource') - expect(apiMap.addCredential).toBe('/auth/plugin/datasource/test-datasource') - expect(apiMap.updateCredential).toBe('/auth/plugin/datasource/test-datasource/update') - expect(apiMap.deleteCredential).toBe('/auth/plugin/datasource/test-datasource/delete') - expect(apiMap.getOauthUrl).toBe('/oauth/plugin/test-datasource/datasource/get-authorization-url') - expect(apiMap.getOauthClientSchema).toBe('') - expect(apiMap.setCustomOauthClient).toBe('/auth/plugin/datasource/test-datasource/custom-client') - expect(apiMap.deleteCustomOAuthClient).toBe('/auth/plugin/datasource/test-datasource/custom-client') - }) - - it('should return empty string for getCredentialSchema in datasource', async () => { - const { useGetApi } = await import('./hooks/use-get-api') - - const pluginPayload = createPluginPayload({ - category: AuthCategory.datasource, - provider: 'test-datasource', - }) - - const apiMap = useGetApi(pluginPayload) - - expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('') - }) - }) - - describe('other categories', () => { - it('should return empty strings for model category', async () => { - const { useGetApi } = await import('./hooks/use-get-api') - - const pluginPayload = createPluginPayload({ - category: AuthCategory.model, - provider: 'test-model', - }) - - const apiMap = useGetApi(pluginPayload) - - expect(apiMap.getCredentialInfo).toBe('') - expect(apiMap.setDefaultCredential).toBe('') - expect(apiMap.getCredentials).toBe('') - expect(apiMap.addCredential).toBe('') - expect(apiMap.updateCredential).toBe('') - expect(apiMap.deleteCredential).toBe('') - expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('') - }) - - it('should return empty strings for trigger category', async () => { - const { useGetApi } = await import('./hooks/use-get-api') - - const pluginPayload = createPluginPayload({ - category: AuthCategory.trigger, - provider: 'test-trigger', - }) - - const apiMap = useGetApi(pluginPayload) - - expect(apiMap.getCredentialInfo).toBe('') - expect(apiMap.setDefaultCredential).toBe('') - }) - }) - - describe('edge cases', () => { - it('should handle empty provider', async () => { - const { useGetApi } = await import('./hooks/use-get-api') - - const pluginPayload = createPluginPayload({ - category: AuthCategory.tool, - provider: '', - }) - - const apiMap = useGetApi(pluginPayload) - - expect(apiMap.getCredentialInfo).toBe('/workspaces/current/tool-provider/builtin//credential/info') - }) - - it('should handle special characters in provider name', async () => { - const { useGetApi } = await import('./hooks/use-get-api') - - const pluginPayload = createPluginPayload({ - category: AuthCategory.tool, - provider: 'test-provider_v2', - }) - - const apiMap = useGetApi(pluginPayload) - - expect(apiMap.getCredentialInfo).toContain('test-provider_v2') - }) - }) -}) - -// ==================== usePluginAuth Hook Tests ==================== -describe('usePluginAuth Hook', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsCurrentWorkspaceManager.mockReturnValue(true) - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [], - supported_credential_types: [], - allow_custom_token: true, - }) - }) - - it('should return isAuthorized false when no credentials', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [], - supported_credential_types: [CredentialTypeEnum.API_KEY], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.isAuthorized).toBe(false) - expect(result.current.credentials).toHaveLength(0) - }) - - it('should return isAuthorized true when credentials exist', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [createCredential()], - supported_credential_types: [CredentialTypeEnum.API_KEY], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.isAuthorized).toBe(true) - expect(result.current.credentials).toHaveLength(1) - }) - - it('should return canOAuth true when oauth2 is supported', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [], - supported_credential_types: [CredentialTypeEnum.OAUTH2], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.canOAuth).toBe(true) - expect(result.current.canApiKey).toBe(false) - }) - - it('should return canApiKey true when api-key is supported', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [], - supported_credential_types: [CredentialTypeEnum.API_KEY], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.canOAuth).toBe(false) - expect(result.current.canApiKey).toBe(true) - }) - - it('should return both canOAuth and canApiKey when both supported', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [], - supported_credential_types: [CredentialTypeEnum.OAUTH2, CredentialTypeEnum.API_KEY], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.canOAuth).toBe(true) - expect(result.current.canApiKey).toBe(true) - }) - - it('should return disabled true when user is not workspace manager', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - mockIsCurrentWorkspaceManager.mockReturnValue(false) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.disabled).toBe(true) - }) - - it('should return disabled false when user is workspace manager', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - mockIsCurrentWorkspaceManager.mockReturnValue(true) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.disabled).toBe(false) - }) - - it('should return notAllowCustomCredential based on allow_custom_token', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [], - supported_credential_types: [], - allow_custom_token: false, - }) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.notAllowCustomCredential).toBe(true) - }) - - it('should return invalidPluginCredentialInfo function', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(typeof result.current.invalidPluginCredentialInfo).toBe('function') - }) - - it('should not fetch when enable is false', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, false), { - wrapper: createWrapper(), - }) - - expect(result.current.isAuthorized).toBe(false) - expect(result.current.credentials).toHaveLength(0) - }) -}) - -// ==================== usePluginAuthAction Hook Tests ==================== -describe('usePluginAuthAction Hook', () => { - beforeEach(() => { - vi.clearAllMocks() - mockDeletePluginCredential.mockResolvedValue({}) - mockSetPluginDefaultCredential.mockResolvedValue({}) - mockUpdatePluginCredential.mockResolvedValue({}) - }) - - it('should return all action handlers', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { - wrapper: createWrapper(), - }) - - expect(result.current.doingAction).toBe(false) - expect(typeof result.current.handleSetDoingAction).toBe('function') - expect(typeof result.current.openConfirm).toBe('function') - expect(typeof result.current.closeConfirm).toBe('function') - expect(result.current.deleteCredentialId).toBe(null) - expect(typeof result.current.setDeleteCredentialId).toBe('function') - expect(typeof result.current.handleConfirm).toBe('function') - expect(result.current.editValues).toBe(null) - expect(typeof result.current.setEditValues).toBe('function') - expect(typeof result.current.handleEdit).toBe('function') - expect(typeof result.current.handleRemove).toBe('function') - expect(typeof result.current.handleSetDefault).toBe('function') - expect(typeof result.current.handleRename).toBe('function') - }) - - it('should open and close confirm dialog', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.openConfirm('test-credential-id') - }) - - expect(result.current.deleteCredentialId).toBe('test-credential-id') - - act(() => { - result.current.closeConfirm() - }) - - expect(result.current.deleteCredentialId).toBe(null) - }) - - it('should handle edit with values', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { - wrapper: createWrapper(), - }) - - const editValues = { key: 'value' } - - act(() => { - result.current.handleEdit('test-id', editValues) - }) - - expect(result.current.editValues).toEqual(editValues) - }) - - it('should handle confirm delete', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - const onUpdate = vi.fn() - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.openConfirm('test-credential-id') - }) - - await act(async () => { - await result.current.handleConfirm() - }) - - expect(mockDeletePluginCredential).toHaveBeenCalledWith({ credential_id: 'test-credential-id' }) - expect(mockNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'common.api.actionSuccess', - }) - expect(onUpdate).toHaveBeenCalled() - expect(result.current.deleteCredentialId).toBe(null) - }) - - it('should not confirm delete when no credential id', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { - wrapper: createWrapper(), - }) - - await act(async () => { - await result.current.handleConfirm() - }) - - expect(mockDeletePluginCredential).not.toHaveBeenCalled() - }) - - it('should handle set default', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - const onUpdate = vi.fn() - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), { - wrapper: createWrapper(), - }) - - await act(async () => { - await result.current.handleSetDefault('test-credential-id') - }) - - expect(mockSetPluginDefaultCredential).toHaveBeenCalledWith('test-credential-id') - expect(mockNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'common.api.actionSuccess', - }) - expect(onUpdate).toHaveBeenCalled() - }) - - it('should handle rename', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - const onUpdate = vi.fn() - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), { - wrapper: createWrapper(), - }) - - await act(async () => { - await result.current.handleRename({ - credential_id: 'test-credential-id', - name: 'New Name', - }) - }) - - expect(mockUpdatePluginCredential).toHaveBeenCalledWith({ - credential_id: 'test-credential-id', - name: 'New Name', - }) - expect(mockNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'common.api.actionSuccess', - }) - expect(onUpdate).toHaveBeenCalled() - }) - - it('should prevent concurrent actions', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.handleSetDoingAction(true) - }) - - act(() => { - result.current.openConfirm('test-credential-id') - }) - - await act(async () => { - await result.current.handleConfirm() - }) - - // Should not call delete when already doing action - expect(mockDeletePluginCredential).not.toHaveBeenCalled() - }) - - it('should handle remove after edit', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.handleEdit('test-credential-id', { key: 'value' }) - }) - - act(() => { - result.current.handleRemove() - }) - - expect(result.current.deleteCredentialId).toBe('test-credential-id') - }) -}) - -// ==================== PluginAuth Component Tests ==================== -describe('PluginAuth Component', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsCurrentWorkspaceManager.mockReturnValue(true) - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [], - 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 PluginAuth = (await import('./plugin-auth')).default - - const pluginPayload = createPluginPayload() - - render( - <PluginAuth pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) - - // Should render authorize button - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should render Authorized when authorized and no children', async () => { - const PluginAuth = (await import('./plugin-auth')).default - - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [createCredential()], - supported_credential_types: [CredentialTypeEnum.API_KEY], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - render( - <PluginAuth pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) - - // Should render authorized content - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should render children when authorized and children provided', async () => { - const PluginAuth = (await import('./plugin-auth')).default - - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [createCredential()], - supported_credential_types: [CredentialTypeEnum.API_KEY], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - render( - <PluginAuth pluginPayload={pluginPayload}> - <div data-testid="custom-children">Custom Content</div> - </PluginAuth>, - { wrapper: createWrapper() }, - ) - - expect(screen.getByTestId('custom-children')).toBeInTheDocument() - expect(screen.getByText('Custom Content')).toBeInTheDocument() - }) - - it('should apply className when not authorized', async () => { - const PluginAuth = (await import('./plugin-auth')).default - - const pluginPayload = createPluginPayload() - - const { container } = render( - <PluginAuth pluginPayload={pluginPayload} className="custom-class" />, - { wrapper: createWrapper() }, - ) - - expect(container.firstChild).toHaveClass('custom-class') - }) - - it('should not apply className when authorized', async () => { - const PluginAuth = (await import('./plugin-auth')).default - - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [createCredential()], - supported_credential_types: [CredentialTypeEnum.API_KEY], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - const { container } = render( - <PluginAuth pluginPayload={pluginPayload} className="custom-class" />, - { wrapper: createWrapper() }, - ) - - expect(container.firstChild).not.toHaveClass('custom-class') - }) - - it('should be memoized', async () => { - const PluginAuthModule = await import('./plugin-auth') - expect(typeof PluginAuthModule.default).toBe('object') - }) -}) - -// ==================== PluginAuthInAgent Component 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() }, - ) - - // Check that button text contains unavailable - 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() }, - ) - - // Click to open popup - const buttons = screen.getAllByRole('button') - fireEvent.click(buttons[0]) - - // Verify popup is opened (there will be multiple buttons after opening) - expect(screen.getAllByRole('button').length).toBeGreaterThan(0) - }) - - it('should trigger handleAuthorizationItemClick and close popup when authorization 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() }, - ) - - // Click trigger button to open popup - const triggerButton = screen.getByRole('button') - fireEvent.click(triggerButton) - - // Find and click the workspace default item in the dropdown - // There will be multiple elements with this text, we need the one in the popup (not the trigger) - const workspaceDefaultItems = screen.getAllByText('plugin.auth.workspaceDefault') - // The second one is in the popup list (first one is the trigger button) - const popupItem = workspaceDefaultItems.length > 1 ? workspaceDefaultItems[1] : workspaceDefaultItems[0] - fireEvent.click(popupItem) - - // Verify onAuthorizationItemClick was called with empty string for workspace default - 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() }, - ) - - // Click trigger button to open popup - const triggerButton = screen.getByRole('button') - fireEvent.click(triggerButton) - - // Find and click the specific credential item - there might be multiple "Specific Credential" texts - const credentialItems = screen.getAllByText('Specific Credential') - // Click the one in the popup (usually the last one if trigger shows different text) - const popupItem = credentialItems[credentialItems.length - 1] - fireEvent.click(popupItem) - - // Verify onAuthorizationItemClick was called with the credential id - 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') - }) -}) - -// ==================== PluginAuthInDataSourceNode Component Tests ==================== -describe('PluginAuthInDataSourceNode Component', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render connect button when not authorized', async () => { - const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default - - const onJumpToDataSourcePage = vi.fn() - - render( - <PluginAuthInDataSourceNode - isAuthorized={false} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - expect(screen.getByText('common.integrations.connect')).toBeInTheDocument() - }) - - it('should call onJumpToDataSourcePage when connect button is clicked', async () => { - const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default - - const onJumpToDataSourcePage = vi.fn() - - render( - <PluginAuthInDataSourceNode - isAuthorized={false} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - fireEvent.click(screen.getByRole('button')) - expect(onJumpToDataSourcePage).toHaveBeenCalledTimes(1) - }) - - it('should render children when authorized', async () => { - const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default - - const onJumpToDataSourcePage = vi.fn() - - render( - <PluginAuthInDataSourceNode - isAuthorized={true} - onJumpToDataSourcePage={onJumpToDataSourcePage} - > - <div data-testid="children-content">Authorized Content</div> - </PluginAuthInDataSourceNode>, - ) - - expect(screen.getByTestId('children-content')).toBeInTheDocument() - expect(screen.getByText('Authorized Content')).toBeInTheDocument() - expect(screen.queryByRole('button')).not.toBeInTheDocument() - }) - - it('should not render connect button when authorized', async () => { - const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default - - const onJumpToDataSourcePage = vi.fn() - - render( - <PluginAuthInDataSourceNode - isAuthorized={true} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - expect(screen.queryByRole('button')).not.toBeInTheDocument() - }) - - it('should not render children when not authorized', async () => { - const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default - - const onJumpToDataSourcePage = vi.fn() - - render( - <PluginAuthInDataSourceNode - isAuthorized={false} - onJumpToDataSourcePage={onJumpToDataSourcePage} - > - <div data-testid="children-content">Authorized Content</div> - </PluginAuthInDataSourceNode>, - ) - - expect(screen.queryByTestId('children-content')).not.toBeInTheDocument() - }) - - it('should handle undefined isAuthorized (falsy)', async () => { - const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default - - const onJumpToDataSourcePage = vi.fn() - - render( - <PluginAuthInDataSourceNode - onJumpToDataSourcePage={onJumpToDataSourcePage} - > - <div data-testid="children-content">Content</div> - </PluginAuthInDataSourceNode>, - ) - - // isAuthorized is undefined, which is falsy, so connect button should be shown - expect(screen.getByRole('button')).toBeInTheDocument() - expect(screen.queryByTestId('children-content')).not.toBeInTheDocument() - }) - - it('should be memoized', async () => { - const PluginAuthInDataSourceNodeModule = await import('./plugin-auth-in-datasource-node') - expect(typeof PluginAuthInDataSourceNodeModule.default).toBe('object') - }) -}) - -// ==================== AuthorizedInDataSourceNode Component Tests ==================== -describe('AuthorizedInDataSourceNode Component', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render with singular authorization text when authorizationsNum is 1', async () => { - const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default - - const onJumpToDataSourcePage = vi.fn() - - render( - <AuthorizedInDataSourceNode - authorizationsNum={1} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - expect(screen.getByRole('button')).toBeInTheDocument() - expect(screen.getByText('plugin.auth.authorization')).toBeInTheDocument() - }) - - it('should render with plural authorizations text when authorizationsNum > 1', async () => { - const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default - - const onJumpToDataSourcePage = vi.fn() - - render( - <AuthorizedInDataSourceNode - authorizationsNum={3} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - expect(screen.getByText('plugin.auth.authorizations')).toBeInTheDocument() - }) - - it('should call onJumpToDataSourcePage when button is clicked', async () => { - const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default - - const onJumpToDataSourcePage = vi.fn() - - render( - <AuthorizedInDataSourceNode - authorizationsNum={1} - onJumpToDataSourcePage={onJumpToDataSourcePage} - />, - ) - - fireEvent.click(screen.getByRole('button')) - expect(onJumpToDataSourcePage).toHaveBeenCalledTimes(1) - }) - - it('should render with green indicator', async () => { - const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default - - const { container } = render( - <AuthorizedInDataSourceNode - authorizationsNum={1} - onJumpToDataSourcePage={vi.fn()} - />, - ) - - // Check that indicator component is rendered - expect(container.querySelector('.mr-1\\.5')).toBeInTheDocument() - }) - - it('should handle authorizationsNum of 0', async () => { - const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default - - render( - <AuthorizedInDataSourceNode - authorizationsNum={0} - onJumpToDataSourcePage={vi.fn()} - />, - ) - - // 0 is not > 1, so should show singular - expect(screen.getByText('plugin.auth.authorization')).toBeInTheDocument() - }) - - it('should be memoized', async () => { - const AuthorizedInDataSourceNodeModule = await import('./authorized-in-data-source-node') - expect(typeof AuthorizedInDataSourceNodeModule.default).toBe('object') - }) -}) - -// ==================== AuthorizedInNode Component 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() }, - ) - - // Check that button text contains unavailable - 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() }, - ) - - // Check that button text contains unavailable - 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() }, - ) - - // Click to open the popup - const buttons = screen.getAllByRole('button') - fireEvent.click(buttons[0]) - - // The popup should be open now - there will be multiple buttons after opening - 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') - }) -}) - -// ==================== useCredential Hooks Tests ==================== -describe('useCredential Hooks', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [], - supported_credential_types: [], - allow_custom_token: true, - }) - }) - - describe('useGetPluginCredentialInfoHook', () => { - it('should return credential info when enabled', async () => { - const { useGetPluginCredentialInfoHook } = await import('./hooks/use-credential') - - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [createCredential()], - supported_credential_types: [CredentialTypeEnum.API_KEY], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => useGetPluginCredentialInfoHook(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.data).toBeDefined() - expect(result.current.data?.credentials).toHaveLength(1) - }) - - it('should not fetch when disabled', async () => { - const { useGetPluginCredentialInfoHook } = await import('./hooks/use-credential') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => useGetPluginCredentialInfoHook(pluginPayload, false), { - wrapper: createWrapper(), - }) - - expect(result.current.data).toBeUndefined() - }) - }) - - describe('useDeletePluginCredentialHook', () => { - it('should return mutateAsync function', async () => { - const { useDeletePluginCredentialHook } = await import('./hooks/use-credential') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => useDeletePluginCredentialHook(pluginPayload), { - wrapper: createWrapper(), - }) - - expect(typeof result.current.mutateAsync).toBe('function') - }) - }) - - describe('useInvalidPluginCredentialInfoHook', () => { - it('should return invalidation function that calls both invalidators', async () => { - const { useInvalidPluginCredentialInfoHook } = await import('./hooks/use-credential') - - const pluginPayload = createPluginPayload({ providerType: 'builtin' }) - - const { result } = renderHook(() => useInvalidPluginCredentialInfoHook(pluginPayload), { - wrapper: createWrapper(), - }) - - expect(typeof result.current).toBe('function') - - result.current() - - expect(mockInvalidPluginCredentialInfo).toHaveBeenCalled() - expect(mockInvalidToolsByType).toHaveBeenCalled() - }) - }) - - describe('useSetPluginDefaultCredentialHook', () => { - it('should return mutateAsync function', async () => { - const { useSetPluginDefaultCredentialHook } = await import('./hooks/use-credential') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => useSetPluginDefaultCredentialHook(pluginPayload), { - wrapper: createWrapper(), - }) - - expect(typeof result.current.mutateAsync).toBe('function') - }) - }) - - describe('useGetPluginCredentialSchemaHook', () => { - it('should return schema data', async () => { - const { useGetPluginCredentialSchemaHook } = await import('./hooks/use-credential') - - mockGetPluginCredentialSchema.mockReturnValue([{ name: 'api_key', type: 'string' }]) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook( - () => useGetPluginCredentialSchemaHook(pluginPayload, CredentialTypeEnum.API_KEY), - { wrapper: createWrapper() }, - ) - - expect(result.current.data).toBeDefined() - }) - }) - - describe('useAddPluginCredentialHook', () => { - it('should return mutateAsync function', async () => { - const { useAddPluginCredentialHook } = await import('./hooks/use-credential') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => useAddPluginCredentialHook(pluginPayload), { - wrapper: createWrapper(), - }) - - expect(typeof result.current.mutateAsync).toBe('function') - }) - }) - - describe('useUpdatePluginCredentialHook', () => { - it('should return mutateAsync function', async () => { - const { useUpdatePluginCredentialHook } = await import('./hooks/use-credential') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => useUpdatePluginCredentialHook(pluginPayload), { - wrapper: createWrapper(), - }) - - expect(typeof result.current.mutateAsync).toBe('function') - }) - }) - - describe('useGetPluginOAuthUrlHook', () => { - it('should return mutateAsync function', async () => { - const { useGetPluginOAuthUrlHook } = await import('./hooks/use-credential') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => useGetPluginOAuthUrlHook(pluginPayload), { - wrapper: createWrapper(), - }) - - expect(typeof result.current.mutateAsync).toBe('function') - }) - }) - - describe('useGetPluginOAuthClientSchemaHook', () => { - it('should return schema data', async () => { - const { useGetPluginOAuthClientSchemaHook } = await import('./hooks/use-credential') - - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: true, - }) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => useGetPluginOAuthClientSchemaHook(pluginPayload), { - wrapper: createWrapper(), - }) - - expect(result.current.data).toBeDefined() - }) - }) - - describe('useSetPluginOAuthCustomClientHook', () => { - it('should return mutateAsync function', async () => { - const { useSetPluginOAuthCustomClientHook } = await import('./hooks/use-credential') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => useSetPluginOAuthCustomClientHook(pluginPayload), { - wrapper: createWrapper(), - }) - - expect(typeof result.current.mutateAsync).toBe('function') - }) - }) - - describe('useDeletePluginOAuthCustomClientHook', () => { - it('should return mutateAsync function', async () => { - const { useDeletePluginOAuthCustomClientHook } = await import('./hooks/use-credential') - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => useDeletePluginOAuthCustomClientHook(pluginPayload), { - wrapper: createWrapper(), - }) - - expect(typeof result.current.mutateAsync).toBe('function') - }) - }) -}) - -// ==================== Edge Cases and Error Handling ==================== -describe('Edge Cases and Error Handling', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsCurrentWorkspaceManager.mockReturnValue(true) - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: [], - 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, - }) - }) - - describe('PluginAuth edge cases', () => { - it('should handle empty provider gracefully', async () => { - const PluginAuth = (await import('./plugin-auth')).default - - const pluginPayload = createPluginPayload({ provider: '' }) - - expect(() => { - render( - <PluginAuth pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) - }).not.toThrow() - }) - - it('should handle tool and datasource auth categories with button', async () => { - const PluginAuth = (await import('./plugin-auth')).default - - // Tool and datasource categories should render with API support - const categoriesWithApi = [AuthCategory.tool] - - for (const category of categoriesWithApi) { - const pluginPayload = createPluginPayload({ category }) - - const { unmount } = render( - <PluginAuth pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) - - expect(screen.getByRole('button')).toBeInTheDocument() - - unmount() - } - }) - - it('should handle model and trigger categories without throwing', async () => { - const PluginAuth = (await import('./plugin-auth')).default - - // Model and trigger categories have empty API endpoints, so they render without buttons - const categoriesWithoutApi = [AuthCategory.model, AuthCategory.trigger] - - for (const category of categoriesWithoutApi) { - const pluginPayload = createPluginPayload({ category }) - - expect(() => { - const { unmount } = render( - <PluginAuth pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) - unmount() - }).not.toThrow() - } - }) - - it('should handle undefined detail', async () => { - const PluginAuth = (await import('./plugin-auth')).default - - const pluginPayload = createPluginPayload({ detail: undefined }) - - expect(() => { - render( - <PluginAuth pluginPayload={pluginPayload} />, - { wrapper: createWrapper() }, - ) - }).not.toThrow() - }) - }) - - describe('usePluginAuthAction error handling', () => { - it('should handle delete error gracefully', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - mockDeletePluginCredential.mockRejectedValue(new Error('Delete failed')) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.openConfirm('test-id') - }) - - // Should not throw, error is caught - await expect( - act(async () => { - await result.current.handleConfirm() - }), - ).rejects.toThrow('Delete failed') - - // Action state should be reset - expect(result.current.doingAction).toBe(false) - }) - - it('should handle set default error gracefully', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - mockSetPluginDefaultCredential.mockRejectedValue(new Error('Set default failed')) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { - wrapper: createWrapper(), - }) - - await expect( - act(async () => { - await result.current.handleSetDefault('test-id') - }), - ).rejects.toThrow('Set default failed') - - expect(result.current.doingAction).toBe(false) - }) - - it('should handle rename error gracefully', async () => { - const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') - - mockUpdatePluginCredential.mockRejectedValue(new Error('Rename failed')) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { - wrapper: createWrapper(), - }) - - await expect( - act(async () => { - await result.current.handleRename({ credential_id: 'test-id', name: 'New Name' }) - }), - ).rejects.toThrow('Rename failed') - - expect(result.current.doingAction).toBe(false) - }) - }) - - describe('Credential list edge cases', () => { - it('should handle large credential lists', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - const largeCredentialList = createCredentialList(100) - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: largeCredentialList, - supported_credential_types: [CredentialTypeEnum.API_KEY], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.isAuthorized).toBe(true) - expect(result.current.credentials).toHaveLength(100) - }) - - it('should handle mixed credential types', async () => { - const { usePluginAuth } = await import('./hooks/use-plugin-auth') - - const mixedCredentials = [ - createCredential({ id: '1', credential_type: CredentialTypeEnum.API_KEY }), - createCredential({ id: '2', credential_type: CredentialTypeEnum.OAUTH2 }), - createCredential({ id: '3', credential_type: undefined }), - ] - mockGetPluginCredentialInfo.mockReturnValue({ - credentials: mixedCredentials, - supported_credential_types: [CredentialTypeEnum.API_KEY, CredentialTypeEnum.OAUTH2], - allow_custom_token: true, - }) - - const pluginPayload = createPluginPayload() - - const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { - wrapper: createWrapper(), - }) - - expect(result.current.credentials).toHaveLength(3) - expect(result.current.canOAuth).toBe(true) - expect(result.current.canApiKey).toBe(true) - }) - }) - - describe('Boundary conditions', () => { - it('should handle special characters in provider name', async () => { - const { useGetApi } = await import('./hooks/use-get-api') - - const pluginPayload = createPluginPayload({ - provider: 'test-provider_v2.0', - }) - - const apiMap = useGetApi(pluginPayload) - - expect(apiMap.getCredentialInfo).toContain('test-provider_v2.0') - }) - - it('should handle very long provider names', async () => { - const { useGetApi } = await import('./hooks/use-get-api') - - const longProvider = 'a'.repeat(200) - const pluginPayload = createPluginPayload({ - provider: longProvider, - }) - - const apiMap = useGetApi(pluginPayload) - - expect(apiMap.getCredentialInfo).toContain(longProvider) - }) - }) -}) diff --git a/web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/action-list.spec.tsx similarity index 88% rename from web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/action-list.spec.tsx index 14ed18eb9a..a2ef24918d 100644 --- a/web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/action-list.spec.tsx @@ -1,18 +1,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import ActionList from './action-list' - -// Mock dependencies -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.num !== undefined) - return `${options.num} ${options.action || 'actions'}` - return key - }, - }), -})) +import ActionList from '../action-list' const mockToolData = [ { name: 'tool-1', label: { en_US: 'Tool 1' } }, @@ -82,7 +71,7 @@ describe('ActionList', () => { const detail = createPluginDetail() render(<ActionList detail={detail} />) - expect(screen.getByText('2 actions')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.actionNum:{"num":2,"action":"actions"}')).toBeInTheDocument() expect(screen.getAllByTestId('tool-item')).toHaveLength(2) }) @@ -124,7 +113,7 @@ describe('ActionList', () => { // The provider key is constructed from plugin_id and tool identity name // When they match the mock, it renders - expect(screen.getByText('2 actions')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.actionNum:{"num":2,"action":"actions"}')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/agent-strategy-list.spec.tsx similarity index 88% rename from web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/agent-strategy-list.spec.tsx index b9b737c51b..34015c0487 100644 --- a/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/agent-strategy-list.spec.tsx @@ -1,17 +1,7 @@ import type { PluginDetail, StrategyDetail } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import AgentStrategyList from './agent-strategy-list' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.num !== undefined) - return `${options.num} ${options.strategy || 'strategies'}` - return key - }, - }), -})) +import AgentStrategyList from '../agent-strategy-list' const mockStrategies = [ { @@ -91,7 +81,7 @@ describe('AgentStrategyList', () => { it('should render strategy items when data is available', () => { render(<AgentStrategyList detail={createPluginDetail()} />) - expect(screen.getByText('1 strategy')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.strategyNum:{"num":1,"strategy":"strategy"}')).toBeInTheDocument() expect(screen.getByTestId('strategy-item')).toBeInTheDocument() }) @@ -114,7 +104,7 @@ describe('AgentStrategyList', () => { } render(<AgentStrategyList detail={createPluginDetail()} />) - expect(screen.getByText('2 strategies')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.strategyNum:{"num":2,"strategy":"strategies"}')).toBeInTheDocument() expect(screen.getAllByTestId('strategy-item')).toHaveLength(2) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx similarity index 85% rename from web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx index e315bbf62b..d5a8b6f473 100644 --- a/web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx @@ -1,17 +1,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import DatasourceActionList from './datasource-action-list' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.num !== undefined) - return `${options.num} ${options.action || 'actions'}` - return key - }, - }), -})) +import DatasourceActionList from '../datasource-action-list' const mockDataSourceList = [ { plugin_id: 'test-plugin', name: 'Data Source 1' }, @@ -72,7 +62,7 @@ describe('DatasourceActionList', () => { render(<DatasourceActionList detail={createPluginDetail()} />) // The component always shows "0 action" because data is hardcoded as empty array - expect(screen.getByText('0 action')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.actionNum:{"num":0,"action":"action"}')).toBeInTheDocument() }) it('should return null when no provider found', () => { @@ -98,7 +88,7 @@ describe('DatasourceActionList', () => { render(<DatasourceActionList detail={detail} />) - expect(screen.getByText('0 action')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.actionNum:{"num":0,"action":"action"}')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx similarity index 94% rename from web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx index cc0ac404b2..a35fcec8be 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx @@ -1,10 +1,10 @@ -import type { PluginDetail } from '../types' +import type { PluginDetail } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import * as amplitude from '@/app/components/base/amplitude' import Toast from '@/app/components/base/toast' -import { PluginSource } from '../types' -import DetailHeader from './detail-header' +import { PluginSource } from '../../types' +import DetailHeader from '../detail-header' const { mockSetShowUpdatePluginModal, @@ -24,12 +24,6 @@ const { } }) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - vi.mock('ahooks', async () => { const React = await import('react') return { @@ -90,7 +84,7 @@ vi.mock('@/service/use-tools', () => ({ useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders, })) -vi.mock('../install-plugin/hooks', () => ({ +vi.mock('../../install-plugin/hooks', () => ({ useGitHubReleases: () => ({ checkForUpdates: mockCheckForUpdates, fetchReleases: mockFetchReleases, @@ -106,13 +100,13 @@ let mockAutoUpgradeInfo: { upgrade_time_of_day: number } | null = null -vi.mock('../plugin-page/use-reference-setting', () => ({ +vi.mock('../../plugin-page/use-reference-setting', () => ({ default: () => ({ referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null, }), })) -vi.mock('../reference-setting-modal/auto-update-setting/types', () => ({ +vi.mock('../../reference-setting-modal/auto-update-setting/types', () => ({ AUTO_UPDATE_MODE: { update_all: 'update_all', partial: 'partial', @@ -120,7 +114,7 @@ vi.mock('../reference-setting-modal/auto-update-setting/types', () => ({ }, })) -vi.mock('../reference-setting-modal/auto-update-setting/utils', () => ({ +vi.mock('../../reference-setting-modal/auto-update-setting/utils', () => ({ convertUTCDaySecondsToLocalSeconds: (seconds: number) => seconds, timeOfDayToDayjs: () => ({ format: () => '10:00 AM' }), })) @@ -137,32 +131,32 @@ vi.mock('@/utils/var', () => ({ getMarketplaceUrl: (path: string) => `https://marketplace.example.com${path}`, })) -vi.mock('../card/base/card-icon', () => ({ +vi.mock('../../card/base/card-icon', () => ({ default: ({ src }: { src: string }) => <div data-testid="card-icon" data-src={src} />, })) -vi.mock('../card/base/description', () => ({ +vi.mock('../../card/base/description', () => ({ default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>, })) -vi.mock('../card/base/org-info', () => ({ +vi.mock('../../card/base/org-info', () => ({ default: ({ orgName }: { orgName: string }) => <div data-testid="org-info">{orgName}</div>, })) -vi.mock('../card/base/title', () => ({ +vi.mock('../../card/base/title', () => ({ default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>, })) -vi.mock('../base/badges/verified', () => ({ +vi.mock('../../base/badges/verified', () => ({ default: () => <span data-testid="verified-badge" />, })) -vi.mock('../base/deprecation-notice', () => ({ +vi.mock('../../base/deprecation-notice', () => ({ default: () => <div data-testid="deprecation-notice" />, })) // Enhanced operation-dropdown mock -vi.mock('./operation-dropdown', () => ({ +vi.mock('../operation-dropdown', () => ({ default: ({ onInfo, onCheckVersion, onRemove }: { onInfo: () => void, onCheckVersion: () => void, onRemove: () => void }) => ( <div data-testid="operation-dropdown"> <button data-testid="info-btn" onClick={onInfo}>Info</button> @@ -173,7 +167,7 @@ vi.mock('./operation-dropdown', () => ({ })) // Enhanced update modal mock -vi.mock('../update-plugin/from-market-place', () => ({ +vi.mock('../../update-plugin/from-market-place', () => ({ default: ({ onSave, onCancel }: { onSave: () => void, onCancel: () => void }) => { return ( <div data-testid="update-modal"> @@ -185,7 +179,7 @@ vi.mock('../update-plugin/from-market-place', () => ({ })) // Enhanced version picker mock -vi.mock('../update-plugin/plugin-version-picker', () => ({ +vi.mock('../../update-plugin/plugin-version-picker', () => ({ default: ({ trigger, onSelect, onShowChange }: { trigger: React.ReactNode, onSelect: (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void, onShowChange: (show: boolean) => void }) => ( <div data-testid="version-picker"> {trigger} @@ -211,7 +205,7 @@ vi.mock('../update-plugin/plugin-version-picker', () => ({ ), })) -vi.mock('../plugin-page/plugin-info', () => ({ +vi.mock('../../plugin-page/plugin-info', () => ({ default: ({ onHide }: { onHide: () => void }) => ( <div data-testid="plugin-info"> <button data-testid="plugin-info-close" onClick={onHide}>Close</button> @@ -219,7 +213,7 @@ vi.mock('../plugin-page/plugin-info', () => ({ ), })) -vi.mock('../plugin-auth', () => ({ +vi.mock('../../plugin-auth', () => ({ AuthCategory: { tool: 'tool' }, PluginAuth: () => <div data-testid="plugin-auth" />, })) @@ -369,7 +363,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - expect(screen.getByText('detailPanel.operation.update')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.update')).toBeInTheDocument() }) it('should show update button for GitHub source', () => { @@ -379,7 +373,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - expect(screen.getByText('detailPanel.operation.update')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.update')).toBeInTheDocument() }) }) @@ -556,7 +550,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - const updateBtn = screen.getByText('detailPanel.operation.update') + const updateBtn = screen.getByText('plugin.detailPanel.operation.update') fireEvent.click(updateBtn) expect(updateBtn).toBeInTheDocument() @@ -589,7 +583,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo') @@ -605,7 +599,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(mockFetchReleases).toHaveBeenCalled() @@ -619,7 +613,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(mockSetShowUpdatePluginModal).toHaveBeenCalled() @@ -638,7 +632,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(mockOnUpdate).toHaveBeenCalled() @@ -916,7 +910,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(screen.getByTestId('update-modal')).toBeInTheDocument() @@ -930,7 +924,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(screen.getByTestId('update-modal')).toBeInTheDocument() }) @@ -949,7 +943,7 @@ describe('DetailHeader', () => { }) render(<DetailHeader detail={detail} onUpdate={mockOnUpdate} onHide={mockOnHide} />) - fireEvent.click(screen.getByText('detailPanel.operation.update')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) await waitFor(() => { expect(screen.getByTestId('update-modal')).toBeInTheDocument() }) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx similarity index 89% rename from web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx index 203bd6a02a..b6710887a5 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx @@ -1,14 +1,8 @@ -import type { EndpointListItem, PluginDetail } from '../types' +import type { EndpointListItem, PluginDetail } from '../../types' import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Toast from '@/app/components/base/toast' -import EndpointCard from './endpoint-card' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import EndpointCard from '../endpoint-card' vi.mock('copy-to-clipboard', () => ({ default: vi.fn(), @@ -76,7 +70,7 @@ vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ addDefaultValue: (value: unknown) => value, })) -vi.mock('./endpoint-modal', () => ({ +vi.mock('../endpoint-modal', () => ({ default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => ( <div data-testid="endpoint-modal"> <button data-testid="modal-cancel" onClick={onCancel}>Cancel</button> @@ -163,7 +157,7 @@ describe('EndpointCard', () => { it('should show active status when enabled', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) - expect(screen.getByText('detailPanel.serviceOk')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.serviceOk')).toBeInTheDocument() expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green') }) @@ -171,7 +165,7 @@ describe('EndpointCard', () => { const disabledData = { ...mockEndpointData, enabled: false } render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />) - expect(screen.getByText('detailPanel.disabled')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.disabled')).toBeInTheDocument() expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'gray') }) }) @@ -182,7 +176,7 @@ describe('EndpointCard', () => { fireEvent.click(screen.getByRole('switch')) - expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointDisableTip')).toBeInTheDocument() }) it('should call disableEndpoint when confirm disable', () => { @@ -190,7 +184,7 @@ describe('EndpointCard', () => { fireEvent.click(screen.getByRole('switch')) // Click confirm button in the Confirm dialog - fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockDisableEndpoint).toHaveBeenCalledWith('ep-1') }) @@ -205,7 +199,7 @@ describe('EndpointCard', () => { if (deleteButton) fireEvent.click(deleteButton) - expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument() }) it('should call deleteEndpoint when confirm delete', () => { @@ -216,7 +210,7 @@ describe('EndpointCard', () => { expect(deleteButton).toBeDefined() if (deleteButton) fireEvent.click(deleteButton) - fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1') }) @@ -290,12 +284,12 @@ describe('EndpointCard', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) fireEvent.click(screen.getByRole('switch')) - expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointDisableTip')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) // Confirm should be hidden - expect(screen.queryByText('detailPanel.endpointDisableTip')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.endpointDisableTip')).not.toBeInTheDocument() }) it('should hide delete confirm when cancel clicked', () => { @@ -306,11 +300,11 @@ describe('EndpointCard', () => { expect(deleteButton).toBeDefined() if (deleteButton) fireEvent.click(deleteButton) - expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - expect(screen.queryByText('detailPanel.endpointDeleteTip')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.endpointDeleteTip')).not.toBeInTheDocument() }) it('should hide edit modal when cancel clicked', () => { @@ -344,7 +338,7 @@ describe('EndpointCard', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) fireEvent.click(screen.getByRole('switch')) - fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockDisableEndpoint).toHaveBeenCalled() }) @@ -357,7 +351,7 @@ describe('EndpointCard', () => { const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary')) if (deleteButton) fireEvent.click(deleteButton) - fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockDeleteEndpoint).toHaveBeenCalled() }) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx similarity index 94% rename from web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx index 0c9865153a..bc25cd816f 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx @@ -1,13 +1,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import EndpointList from './endpoint-list' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import EndpointList from '../endpoint-list' vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.com${path}`, @@ -41,13 +35,13 @@ vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ toolCredentialToFormSchemas: (schemas: unknown[]) => schemas, })) -vi.mock('./endpoint-card', () => ({ +vi.mock('../endpoint-card', () => ({ default: ({ data }: { data: { name: string } }) => ( <div data-testid="endpoint-card">{data.name}</div> ), })) -vi.mock('./endpoint-modal', () => ({ +vi.mock('../endpoint-modal', () => ({ default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => ( <div data-testid="endpoint-modal"> <button data-testid="modal-cancel" onClick={onCancel}>Cancel</button> @@ -91,7 +85,7 @@ describe('EndpointList', () => { it('should render endpoint list', () => { render(<EndpointList detail={createPluginDetail()} />) - expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpoints')).toBeInTheDocument() }) it('should render endpoint cards', () => { @@ -112,7 +106,7 @@ describe('EndpointList', () => { mockEndpointListData = { endpoints: [] } render(<EndpointList detail={createPluginDetail()} />) - expect(screen.getByText('detailPanel.endpointsEmpty')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointsEmpty')).toBeInTheDocument() }) it('should render add button', () => { @@ -165,7 +159,7 @@ describe('EndpointList', () => { render(<EndpointList detail={detail} />) // Verify the component renders correctly - expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpoints')).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx similarity index 88% rename from web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx index 96fa647e91..4ed7ec48a5 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx @@ -1,19 +1,9 @@ -import type { FormSchema } from '../../base/form/types' -import type { PluginDetail } from '../types' +import type { FormSchema } from '../../../base/form/types' +import type { PluginDetail } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Toast from '@/app/components/base/toast' -import EndpointModal from './endpoint-modal' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, opts?: Record<string, unknown>) => { - if (opts?.field) - return `${key}: ${opts.field}` - return key - }, - }), -})) +import EndpointModal from '../endpoint-modal' vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => (obj: Record<string, string> | string) => @@ -45,7 +35,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal }, })) -vi.mock('../readme-panel/entrance', () => ({ +vi.mock('../../readme-panel/entrance', () => ({ ReadmeEntrance: () => <div data-testid="readme-entrance" />, })) @@ -110,8 +100,8 @@ describe('EndpointModal', () => { />, ) - expect(screen.getByText('detailPanel.endpointModalTitle')).toBeInTheDocument() - expect(screen.getByText('detailPanel.endpointModalDesc')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointModalTitle')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.endpointModalDesc')).toBeInTheDocument() }) it('should render form with fieldMoreInfo url link', () => { @@ -125,8 +115,7 @@ describe('EndpointModal', () => { ) expect(screen.getByTestId('field-more-info')).toBeInTheDocument() - // Should render the "howToGet" link when url exists - expect(screen.getByText('howToGet')).toBeInTheDocument() + expect(screen.getByText('tools.howToGet')).toBeInTheDocument() }) it('should render readme entrance', () => { @@ -154,7 +143,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) expect(mockOnCancel).toHaveBeenCalledTimes(1) }) @@ -260,7 +249,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -283,7 +272,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -302,7 +291,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ name: 'Valid Name' }) }) @@ -321,7 +310,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockToastNotify).not.toHaveBeenCalled() expect(mockOnSaved).toHaveBeenCalled() @@ -344,7 +333,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) }) @@ -364,7 +353,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) }) @@ -384,7 +373,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) }) @@ -404,7 +393,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) }) @@ -424,7 +413,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) }) @@ -444,7 +433,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) }) @@ -464,7 +453,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) }) @@ -484,7 +473,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) }) @@ -504,7 +493,7 @@ describe('EndpointModal', () => { />, ) - fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(mockOnSaved).toHaveBeenCalledWith({ text: 'hello' }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-detail-panel/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/index.spec.tsx index 0cc9671e1b..c3989ab71f 100644 --- a/web/app/components/plugins/plugin-detail-panel/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/index.spec.tsx @@ -2,11 +2,11 @@ import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/t import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' -import PluginDetailPanel from './index' +import PluginDetailPanel from '../index' // Mock store const mockSetDetail = vi.fn() -vi.mock('./store', () => ({ +vi.mock('../store', () => ({ usePluginStore: () => ({ setDetail: mockSetDetail, }), @@ -14,7 +14,7 @@ vi.mock('./store', () => ({ // Mock DetailHeader const mockDetailHeaderOnUpdate = vi.fn() -vi.mock('./detail-header', () => ({ +vi.mock('../detail-header', () => ({ default: ({ detail, onUpdate, onHide }: { detail: PluginDetail onUpdate: (isDelete?: boolean) => void @@ -49,7 +49,7 @@ vi.mock('./detail-header', () => ({ })) // Mock ActionList -vi.mock('./action-list', () => ({ +vi.mock('../action-list', () => ({ default: ({ detail }: { detail: PluginDetail }) => ( <div data-testid="action-list"> <span data-testid="action-list-plugin-id">{detail.plugin_id}</span> @@ -58,7 +58,7 @@ vi.mock('./action-list', () => ({ })) // Mock AgentStrategyList -vi.mock('./agent-strategy-list', () => ({ +vi.mock('../agent-strategy-list', () => ({ default: ({ detail }: { detail: PluginDetail }) => ( <div data-testid="agent-strategy-list"> <span data-testid="strategy-list-plugin-id">{detail.plugin_id}</span> @@ -67,7 +67,7 @@ vi.mock('./agent-strategy-list', () => ({ })) // Mock EndpointList -vi.mock('./endpoint-list', () => ({ +vi.mock('../endpoint-list', () => ({ default: ({ detail }: { detail: PluginDetail }) => ( <div data-testid="endpoint-list"> <span data-testid="endpoint-list-plugin-id">{detail.plugin_id}</span> @@ -76,7 +76,7 @@ vi.mock('./endpoint-list', () => ({ })) // Mock ModelList -vi.mock('./model-list', () => ({ +vi.mock('../model-list', () => ({ default: ({ detail }: { detail: PluginDetail }) => ( <div data-testid="model-list"> <span data-testid="model-list-plugin-id">{detail.plugin_id}</span> @@ -85,7 +85,7 @@ vi.mock('./model-list', () => ({ })) // Mock DatasourceActionList -vi.mock('./datasource-action-list', () => ({ +vi.mock('../datasource-action-list', () => ({ default: ({ detail }: { detail: PluginDetail }) => ( <div data-testid="datasource-action-list"> <span data-testid="datasource-list-plugin-id">{detail.plugin_id}</span> @@ -94,7 +94,7 @@ vi.mock('./datasource-action-list', () => ({ })) // Mock SubscriptionList -vi.mock('./subscription-list', () => ({ +vi.mock('../subscription-list', () => ({ SubscriptionList: ({ pluginDetail }: { pluginDetail: PluginDetail }) => ( <div data-testid="subscription-list"> <span data-testid="subscription-list-plugin-id">{pluginDetail.plugin_id}</span> @@ -103,14 +103,14 @@ vi.mock('./subscription-list', () => ({ })) // Mock TriggerEventsList -vi.mock('./trigger/event-list', () => ({ +vi.mock('../trigger/event-list', () => ({ TriggerEventsList: () => ( <div data-testid="trigger-events-list">Events List</div> ), })) // Mock ReadmeEntrance -vi.mock('../readme-panel/entrance', () => ({ +vi.mock('../../readme-panel/entrance', () => ({ ReadmeEntrance: ({ pluginDetail, className }: { pluginDetail: PluginDetail, className?: string }) => ( <div data-testid="readme-entrance" className={className}> <span data-testid="readme-plugin-id">{pluginDetail.plugin_id}</span> diff --git a/web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/model-list.spec.tsx similarity index 87% rename from web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/model-list.spec.tsx index 2283ad0c43..a01c238ced 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/model-list.spec.tsx @@ -1,17 +1,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import ModelList from './model-list' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.num !== undefined) - return `${options.num} models` - return key - }, - }), -})) +import ModelList from '../model-list' const mockModels = [ { model: 'gpt-4', provider: 'openai' }, @@ -72,7 +62,7 @@ describe('ModelList', () => { it('should render model list when data is available', () => { render(<ModelList detail={createPluginDetail()} />) - expect(screen.getByText('2 models')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.modelNum:{"num":2}')).toBeInTheDocument() }) it('should render model icons and names', () => { @@ -96,7 +86,7 @@ describe('ModelList', () => { mockModelListResponse = { data: [] } render(<ModelList detail={createPluginDetail()} />) - expect(screen.getByText('0 models')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.modelNum:{"num":0}')).toBeInTheDocument() expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx similarity index 81% rename from web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx index 5501526b12..7379927ffd 100644 --- a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx @@ -1,14 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginSource } from '../types' -import OperationDropdown from './operation-dropdown' - -// Mock dependencies -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import { PluginSource } from '../../types' +import OperationDropdown from '../operation-dropdown' vi.mock('@/context/global-public-context', () => ({ useGlobalPublicStore: <T,>(selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => T): T => @@ -72,55 +65,55 @@ describe('OperationDropdown', () => { it('should render info option for github source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - expect(screen.getByText('detailPanel.operation.info')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.info')).toBeInTheDocument() }) it('should render check update option for github source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - expect(screen.getByText('detailPanel.operation.checkUpdate')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.checkUpdate')).toBeInTheDocument() }) it('should render view detail option for github source with marketplace enabled', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.viewDetail')).toBeInTheDocument() }) it('should render view detail option for marketplace source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />) - expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.viewDetail')).toBeInTheDocument() }) it('should always render remove option', () => { render(<OperationDropdown {...defaultProps} />) - expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.remove')).toBeInTheDocument() }) it('should not render info option for marketplace source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />) - expect(screen.queryByText('detailPanel.operation.info')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.operation.info')).not.toBeInTheDocument() }) it('should not render check update option for marketplace source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />) - expect(screen.queryByText('detailPanel.operation.checkUpdate')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.operation.checkUpdate')).not.toBeInTheDocument() }) it('should not render view detail for local source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.local} />) - expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.operation.viewDetail')).not.toBeInTheDocument() }) it('should not render view detail for debugging source', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.debugging} />) - expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.operation.viewDetail')).not.toBeInTheDocument() }) }) @@ -138,7 +131,7 @@ describe('OperationDropdown', () => { it('should call onInfo when info option is clicked', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - fireEvent.click(screen.getByText('detailPanel.operation.info')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.info')) expect(mockOnInfo).toHaveBeenCalledTimes(1) }) @@ -146,7 +139,7 @@ describe('OperationDropdown', () => { it('should call onCheckVersion when check update option is clicked', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - fireEvent.click(screen.getByText('detailPanel.operation.checkUpdate')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.checkUpdate')) expect(mockOnCheckVersion).toHaveBeenCalledTimes(1) }) @@ -154,7 +147,7 @@ describe('OperationDropdown', () => { it('should call onRemove when remove option is clicked', () => { render(<OperationDropdown {...defaultProps} />) - fireEvent.click(screen.getByText('detailPanel.operation.remove')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.remove')) expect(mockOnRemove).toHaveBeenCalledTimes(1) }) @@ -162,7 +155,7 @@ describe('OperationDropdown', () => { it('should have correct href for view detail link', () => { render(<OperationDropdown {...defaultProps} source={PluginSource.github} />) - const link = screen.getByText('detailPanel.operation.viewDetail').closest('a') + const link = screen.getByText('plugin.detailPanel.operation.viewDetail').closest('a') expect(link).toHaveAttribute('href', 'https://github.com/test/repo') expect(link).toHaveAttribute('target', '_blank') }) @@ -182,7 +175,7 @@ describe('OperationDropdown', () => { <OperationDropdown {...defaultProps} source={source} />, ) expect(screen.getByTestId('portal-elem')).toBeInTheDocument() - expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.remove')).toBeInTheDocument() unmount() }) }) @@ -197,7 +190,7 @@ describe('OperationDropdown', () => { const { unmount } = render( <OperationDropdown {...defaultProps} detailUrl={url} source={PluginSource.github} />, ) - const link = screen.getByText('detailPanel.operation.viewDetail').closest('a') + const link = screen.getByText('plugin.detailPanel.operation.viewDetail').closest('a') expect(link).toHaveAttribute('href', url) unmount() }) diff --git a/web/app/components/plugins/plugin-detail-panel/store.spec.ts b/web/app/components/plugins/plugin-detail-panel/__tests__/store.spec.ts similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/store.spec.ts rename to web/app/components/plugins/plugin-detail-panel/__tests__/store.spec.ts index 4116bb9790..3afcc95288 100644 --- a/web/app/components/plugins/plugin-detail-panel/store.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/store.spec.ts @@ -1,7 +1,7 @@ -import type { SimpleDetail } from './store' +import type { SimpleDetail } from '../store' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it } from 'vitest' -import { usePluginStore } from './store' +import { usePluginStore } from '../store' // Factory function to create mock SimpleDetail const createSimpleDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail => ({ diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx similarity index 93% rename from web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx index 32ae6ff735..6203545943 100644 --- a/web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx @@ -1,13 +1,7 @@ import type { StrategyDetail as StrategyDetailType } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import StrategyDetail from './strategy-detail' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import StrategyDetail from '../strategy-detail' vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => (obj: Record<string, string>) => obj?.en_US || '', @@ -93,7 +87,7 @@ describe('StrategyDetail', () => { it('should render parameters section', () => { render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />) - expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.parameters')).toBeInTheDocument() expect(screen.getByText('Parameter 1')).toBeInTheDocument() }) @@ -141,7 +135,7 @@ describe('StrategyDetail', () => { } render(<StrategyDetail provider={mockProvider} detail={detailWithNumber} onHide={mockOnHide} />) - expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.number')).toBeInTheDocument() }) it('should display correct type for checkbox', () => { @@ -161,7 +155,7 @@ describe('StrategyDetail', () => { } render(<StrategyDetail provider={mockProvider} detail={detailWithFile} onHide={mockOnHide} />) - expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.file')).toBeInTheDocument() }) it('should display correct type for array[tools]', () => { @@ -190,7 +184,7 @@ describe('StrategyDetail', () => { const detailEmpty = { ...mockDetail, parameters: [] } render(<StrategyDetail provider={mockProvider} detail={detailEmpty} onHide={mockOnHide} />) - expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.parameters')).toBeInTheDocument() }) it('should handle no output schema', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx similarity index 97% rename from web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx index fde2f82965..31afeaf9f1 100644 --- a/web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx @@ -1,7 +1,7 @@ import type { StrategyDetail } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import StrategyItem from './strategy-item' +import StrategyItem from '../strategy-item' vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => (obj: Record<string, string>) => obj?.en_US || '', @@ -11,7 +11,7 @@ vi.mock('@/utils/classnames', () => ({ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), })) -vi.mock('./strategy-detail', () => ({ +vi.mock('../strategy-detail', () => ({ default: ({ onHide }: { onHide: () => void }) => ( <div data-testid="strategy-detail-panel"> <button data-testid="hide-btn" onClick={onHide}>Hide</button> diff --git a/web/app/components/plugins/plugin-detail-panel/utils.spec.ts b/web/app/components/plugins/plugin-detail-panel/__tests__/utils.spec.ts similarity index 98% rename from web/app/components/plugins/plugin-detail-panel/utils.spec.ts rename to web/app/components/plugins/plugin-detail-panel/__tests__/utils.spec.ts index 6c911d5ebd..602badc9c5 100644 --- a/web/app/components/plugins/plugin-detail-panel/utils.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/utils.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { NAME_FIELD } from './utils' +import { NAME_FIELD } from '../utils' describe('utils', () => { describe('NAME_FIELD', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx new file mode 100644 index 0000000000..d52a62a2ee --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx @@ -0,0 +1,46 @@ +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/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() + }) + + 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 render when open', () => { + const { container } = render(<AppTrigger open={true} />) + + expect(container.firstChild).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/app-selector/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index fd66e7c45e..5497786794 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -6,12 +6,12 @@ import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { InputVarType } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' -import AppInputsForm from './app-inputs-form' -import AppInputsPanel from './app-inputs-panel' -import AppPicker from './app-picker' -import AppTrigger from './app-trigger' +import AppInputsForm from '../app-inputs-form' +import AppInputsPanel from '../app-inputs-panel' +import AppPicker from '../app-picker' +import AppTrigger from '../app-trigger' -import AppSelector from './index' +import AppSelector from '../index' // ==================== Mock Setup ==================== diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx index 4011ee13f5..843800c190 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx @@ -1,15 +1,9 @@ -import type { PluginDetail } from '../../../types' -import type { ModalStates, VersionTarget } from '../hooks' +import type { PluginDetail } from '../../../../types' +import type { ModalStates, VersionTarget } from '../../hooks' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginSource } from '../../../types' -import HeaderModals from './header-modals' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import { PluginSource } from '../../../../types' +import HeaderModals from '../header-modals' vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en_US', @@ -270,7 +264,7 @@ describe('HeaderModals', () => { />, ) - expect(screen.getByTestId('delete-title')).toHaveTextContent('action.delete') + expect(screen.getByTestId('delete-title')).toHaveTextContent('plugin.action.delete') }) it('should call hideDeleteConfirm when cancel is clicked', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx similarity index 89% rename from web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx index e2fa1f6140..4d60433efb 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx @@ -1,13 +1,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginSource } from '../../../types' -import PluginSourceBadge from './plugin-source-badge' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import { PluginSource } from '../../../../types' +import PluginSourceBadge from '../plugin-source-badge' vi.mock('@/app/components/base/tooltip', () => ({ default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( @@ -28,7 +22,7 @@ describe('PluginSourceBadge', () => { const tooltip = screen.getByTestId('tooltip') expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.marketplace') + expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.marketplace') }) it('should render github source badge', () => { @@ -36,7 +30,7 @@ describe('PluginSourceBadge', () => { const tooltip = screen.getByTestId('tooltip') expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.github') + expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.github') }) it('should render local source badge', () => { @@ -44,7 +38,7 @@ describe('PluginSourceBadge', () => { const tooltip = screen.getByTestId('tooltip') expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.local') + expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.local') }) it('should render debugging source badge', () => { @@ -52,7 +46,7 @@ describe('PluginSourceBadge', () => { const tooltip = screen.getByTestId('tooltip') expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.debugging') + expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.debugging') }) }) @@ -94,7 +88,7 @@ describe('PluginSourceBadge', () => { expect(screen.getByTestId('tooltip')).toHaveAttribute( 'data-content', - 'detailPanel.categoryTip.marketplace', + 'plugin.detailPanel.categoryTip.marketplace', ) }) @@ -103,7 +97,7 @@ describe('PluginSourceBadge', () => { expect(screen.getByTestId('tooltip')).toHaveAttribute( 'data-content', - 'detailPanel.categoryTip.github', + 'plugin.detailPanel.categoryTip.github', ) }) @@ -112,7 +106,7 @@ describe('PluginSourceBadge', () => { expect(screen.getByTestId('tooltip')).toHaveAttribute( 'data-content', - 'detailPanel.categoryTip.local', + 'plugin.detailPanel.categoryTip.local', ) }) @@ -121,7 +115,7 @@ describe('PluginSourceBadge', () => { expect(screen.getByTestId('tooltip')).toHaveAttribute( 'data-content', - 'detailPanel.categoryTip.debugging', + 'plugin.detailPanel.categoryTip.debugging', ) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts similarity index 97% rename from web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.spec.ts rename to web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts index 2e14fed60a..044d03ca61 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts @@ -1,8 +1,8 @@ -import type { PluginDetail } from '../../../types' +import type { PluginDetail } from '../../../../types' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginSource } from '../../../types' -import { useDetailHeaderState } from './use-detail-header-state' +import { PluginSource } from '../../../../types' +import { useDetailHeaderState } from '../use-detail-header-state' let mockEnableMarketplace = true vi.mock('@/context/global-public-context', () => ({ @@ -18,13 +18,13 @@ let mockAutoUpgradeInfo: { upgrade_time_of_day: number } | null = null -vi.mock('../../../plugin-page/use-reference-setting', () => ({ +vi.mock('../../../../plugin-page/use-reference-setting', () => ({ default: () => ({ referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null, }), })) -vi.mock('../../../reference-setting-modal/auto-update-setting/types', () => ({ +vi.mock('../../../../reference-setting-modal/auto-update-setting/types', () => ({ AUTO_UPDATE_MODE: { update_all: 'update_all', partial: 'partial', diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts similarity index 98% rename from web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.spec.ts rename to web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts index 683c4080ea..15397ab6fc 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts @@ -1,11 +1,11 @@ -import type { PluginDetail } from '../../../types' -import type { ModalStates, VersionTarget } from './use-detail-header-state' +import type { PluginDetail } from '../../../../types' +import type { ModalStates, VersionTarget } from '../use-detail-header-state' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import * as amplitude from '@/app/components/base/amplitude' import Toast from '@/app/components/base/toast' -import { PluginSource } from '../../../types' -import { usePluginOperations } from './use-plugin-operations' +import { PluginSource } from '../../../../types' +import { usePluginOperations } from '../use-plugin-operations' type VersionPickerMock = { setTargetVersion: (version: VersionTarget) => void @@ -50,7 +50,7 @@ vi.mock('@/service/use-tools', () => ({ useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders, })) -vi.mock('../../../install-plugin/hooks', () => ({ +vi.mock('../../../../install-plugin/hooks', () => ({ useGitHubReleases: () => ({ checkForUpdates: mockCheckForUpdates, fetchReleases: mockFetchReleases, diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/model-selector/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx index 91c978ad7d..e5750d007b 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import Toast from '@/app/components/base/toast' import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import ModelParameterModal from './index' +import ModelParameterModal from '../index' // ==================== Mock Setup ==================== @@ -159,7 +159,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-param ), })) -vi.mock('./llm-params-panel', () => ({ +vi.mock('../llm-params-panel', () => ({ default: ({ provider, modelId, onCompletionParamsChange, isAdvancedMode }: { provider: string modelId: string @@ -179,7 +179,7 @@ vi.mock('./llm-params-panel', () => ({ ), })) -vi.mock('./tts-params-panel', () => ({ +vi.mock('../tts-params-panel', () => ({ default: ({ language, voice, onChange }: { currentModel?: ModelItem language?: string diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx index 27505146b0..17fad8d7a7 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import component after mocks -import LLMParamsPanel from './llm-params-panel' +import LLMParamsPanel from '../llm-params-panel' // ==================== Mock Setup ==================== // All vi.mock() calls are hoisted, so inline all mock data diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx index 304bd563f7..a5633b30d1 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import component after mocks -import TTSParamsPanel from './tts-params-panel' +import TTSParamsPanel from '../tts-params-panel' // ==================== Mock Setup ==================== // All vi.mock() calls are hoisted, so inline all mock data diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx index 288289b64d..c5defa3ab0 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx @@ -8,7 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // ==================== Imports (after mocks) ==================== import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' -import MultipleToolSelector from './index' +import MultipleToolSelector from '../index' // ==================== Mock Setup ==================== @@ -30,9 +30,9 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ onSelectMultiple, onDelete, controlledState, - onControlledStateChange, + onControlledStateChange: _onControlledStateChange, panelShowState, - onPanelShowStateChange, + onPanelShowStateChange: _onPanelShowStateChange, isEdit, supportEnableSwitch, }: { @@ -150,15 +150,15 @@ const createMCPTool = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvi author: 'test-author', type: 'mcp', icon: 'test-icon.png', - label: { en_US: 'MCP Provider' } as any, - description: { en_US: 'MCP Provider description' } as any, + label: { en_US: 'MCP Provider' } as unknown as ToolWithProvider['label'], + description: { en_US: 'MCP Provider description' } as unknown as ToolWithProvider['description'], is_team_authorization: true, allow_delete: false, labels: [], tools: [{ name: 'mcp-tool-1', - label: { en_US: 'MCP Tool 1' } as any, - description: { en_US: 'MCP Tool 1 description' } as any, + label: { en_US: 'MCP Tool 1' } as unknown as ToolWithProvider['label'], + description: { en_US: 'MCP Tool 1 description' } as unknown as ToolWithProvider['description'], parameters: [], output_schema: {}, }], @@ -641,7 +641,7 @@ describe('MultipleToolSelector', () => { it('should handle undefined value', () => { // Arrange & Act - value defaults to [] in component - renderComponent({ value: undefined as any }) + renderComponent({ value: undefined as unknown as ToolValue[] }) // Assert expect(screen.getByText('plugin.detailPanel.toolSelector.empty')).toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx similarity index 96% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx index d9e1bf9cc3..2f5dfe4256 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx @@ -1,12 +1,12 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { DeleteConfirm } from './delete-confirm' +import { DeleteConfirm } from '../delete-confirm' const mockRefetch = vi.fn() const mockDelete = vi.fn() const mockToast = vi.fn() -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx index 5c71977bc7..837a679b4b 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx @@ -3,8 +3,8 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { SubscriptionList } from './index' -import { SubscriptionListMode } from './types' +import { SubscriptionList } from '../index' +import { SubscriptionListMode } from '../types' const mockRefetch = vi.fn() let mockSubscriptionListError: Error | null = null @@ -16,7 +16,7 @@ let mockSubscriptionListState: { let mockPluginDetail: PluginDetail | undefined -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => { if (mockSubscriptionListError) throw mockSubscriptionListError @@ -24,7 +24,7 @@ vi.mock('./use-subscription-list', () => ({ }, })) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: (selector: (state: { detail: PluginDetail | undefined }) => PluginDetail | undefined) => selector({ detail: mockPluginDetail }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx similarity index 93% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx index bac4b5f8ff..7a849d8cd9 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx @@ -2,15 +2,15 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { SubscriptionListView } from './list-view' +import { SubscriptionListView } from '../list-view' let mockSubscriptions: TriggerSubscription[] = [] -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ subscriptions: mockSubscriptions }), })) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: undefined }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx index 44e041d6e2..b131def3c7 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx @@ -1,7 +1,7 @@ import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import LogViewer from './log-viewer' +import LogViewer from '../log-viewer' const mockToastNotify = vi.fn() const mockWriteText = vi.fn() diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx similarity index 95% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx index 09ea047e40..d8d41ff9b2 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx @@ -2,12 +2,12 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { SubscriptionSelectorEntry } from './selector-entry' +import { SubscriptionSelectorEntry } from '../selector-entry' let mockSubscriptions: TriggerSubscription[] = [] const mockRefetch = vi.fn() -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ subscriptions: mockSubscriptions, isLoading: false, @@ -15,7 +15,7 @@ vi.mock('./use-subscription-list', () => ({ }), })) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: undefined }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx similarity index 97% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx index eeba994602..48fe2e52c4 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx @@ -2,7 +2,7 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { SubscriptionSelectorView } from './selector-view' +import { SubscriptionSelectorView } from '../selector-view' let mockSubscriptions: TriggerSubscription[] = [] const mockRefetch = vi.fn() @@ -10,11 +10,11 @@ const mockDelete = vi.fn((_: string, options?: { onSuccess?: () => void }) => { options?.onSuccess?.() }) -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ subscriptions: mockSubscriptions, refetch: mockRefetch }), })) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: undefined }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx similarity index 95% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx index e707ab0b01..cafd8178cf 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx @@ -2,15 +2,15 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import SubscriptionCard from './subscription-card' +import SubscriptionCard from '../subscription-card' const mockRefetch = vi.fn() -vi.mock('./use-subscription-list', () => ({ +vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: { id: 'detail-1', diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/use-subscription-list.spec.ts similarity index 93% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.spec.ts rename to web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/use-subscription-list.spec.ts index 1f462344bf..fc8a0e4642 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/use-subscription-list.spec.ts @@ -1,7 +1,7 @@ -import type { SimpleDetail } from '../store' +import type { SimpleDetail } from '../../store' import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useSubscriptionList } from './use-subscription-list' +import { useSubscriptionList } from '../use-subscription-list' let mockDetail: SimpleDetail | undefined const mockRefetch = vi.fn() @@ -12,7 +12,7 @@ vi.mock('@/service/use-triggers', () => ({ useTriggerSubscriptions: (...args: unknown[]) => mockTriggerSubscriptions(...args), })) -vi.mock('../store', () => ({ +vi.mock('../../store', () => ({ usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) => selector({ detail: mockDetail }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx index 2c9d0f5002..20eac10903 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SupportedCreationMethods } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { CommonCreateModal } from './common-modal' +import { CommonCreateModal } from '../common-modal' type PluginDetail = { plugin_id: string @@ -67,12 +67,12 @@ function createMockLogData(logs: TriggerLogEntity[] = []): { logs: TriggerLogEnt const mockPluginDetail = createMockPluginDetail() const mockUsePluginStore = vi.fn(() => mockPluginDetail) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => mockUsePluginStore(), })) const mockRefetch = vi.fn() -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch, }), @@ -244,7 +244,7 @@ vi.mock('@/app/components/base/encrypted-bottom', () => ({ EncryptedBottom: () => <div data-testid="encrypted-bottom">Encrypted</div>, })) -vi.mock('../log-viewer', () => ({ +vi.mock('../../log-viewer', () => ({ default: ({ logs }: { logs: TriggerLogEntity[] }) => ( <div data-testid="log-viewer"> {logs.map(log => ( diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx index 8520d7e2e9..3fe9884b92 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ -import type { SimpleDetail } from '../../store' +import type { SimpleDetail } from '../../../store' import type { TriggerOAuthConfig, TriggerProviderApiEntity, TriggerSubscription, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SupportedCreationMethods } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from './index' +import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from '../index' let mockPortalOpenState = false @@ -40,14 +40,14 @@ vi.mock('@/app/components/base/toast', () => ({ })) let mockStoreDetail: SimpleDetail | undefined -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) => selector({ detail: mockStoreDetail }), })) const mockSubscriptions: TriggerSubscription[] = [] const mockRefetch = vi.fn() -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ subscriptions: mockSubscriptions, refetch: mockRefetch, @@ -72,7 +72,7 @@ vi.mock('@/hooks/use-oauth', () => ({ }), })) -vi.mock('./common-modal', () => ({ +vi.mock('../common-modal', () => ({ CommonCreateModal: ({ createType, onClose, builder }: { createType: SupportedCreationMethods onClose: () => void @@ -88,7 +88,7 @@ vi.mock('./common-modal', () => ({ ), })) -vi.mock('./oauth-client', () => ({ +vi.mock('../oauth-client', () => ({ OAuthClientSettingsModal: ({ oauthConfig, onClose, showOAuthCreateModal }: { oauthConfig?: TriggerOAuthConfig onClose: () => void diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx index 93cbbd518b..12419a9bf3 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { OAuthClientSettingsModal } from './oauth-client' +import { OAuthClientSettingsModal } from '../oauth-client' type PluginDetail = { plugin_id: string @@ -56,7 +56,7 @@ function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBui const mockPluginDetail = createMockPluginDetail() const mockUsePluginStore = vi.fn(() => mockPluginDetail) -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => mockUsePluginStore(), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.spec.ts rename to web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts index de54a2b87c..89566f3af7 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts @@ -7,7 +7,7 @@ import { ClientTypeEnum, getErrorMessage, useOAuthClientState, -} from './use-oauth-client-state' +} from '../use-oauth-client-state' // ============================================================================ // Mock Factory Functions diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx similarity index 95% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx index e5e82d4c0e..af145df2da 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/apikey-edit-modal.spec.tsx @@ -2,14 +2,14 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { ApiKeyEditModal } from './apikey-edit-modal' +import { ApiKeyEditModal } from '../apikey-edit-modal' const mockRefetch = vi.fn() const mockUpdate = vi.fn() const mockVerify = vi.fn() const mockToast = vi.fn() -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: { id: 'detail-1', @@ -37,7 +37,7 @@ vi.mock('../../store', () => ({ }), })) -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx index a6162967f0..7d188a3f6d 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx @@ -5,10 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { FormTypeEnum } from '@/app/components/base/form/types' import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { ApiKeyEditModal } from './apikey-edit-modal' -import { EditModal } from './index' -import { ManualEditModal } from './manual-edit-modal' -import { OAuthEditModal } from './oauth-edit-modal' +import { ApiKeyEditModal } from '../apikey-edit-modal' +import { EditModal } from '../index' +import { ManualEditModal } from '../manual-edit-modal' +import { OAuthEditModal } from '../oauth-edit-modal' // ==================== Mock Setup ==================== @@ -63,13 +63,13 @@ const mockPluginStoreDetail = { }, } -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: (selector: (state: { detail: typeof mockPluginStoreDetail }) => unknown) => selector({ detail: mockPluginStoreDetail }), })) const mockRefetch = vi.fn() -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx similarity index 95% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx index 048c20eeeb..c6144542ab 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/manual-edit-modal.spec.tsx @@ -2,13 +2,13 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { ManualEditModal } from './manual-edit-modal' +import { ManualEditModal } from '../manual-edit-modal' const mockRefetch = vi.fn() const mockUpdate = vi.fn() const mockToast = vi.fn() -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: { id: 'detail-1', @@ -21,7 +21,7 @@ vi.mock('../../store', () => ({ }), })) -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx similarity index 95% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx index ccbe4792ac..7bdcdbc936 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/oauth-edit-modal.spec.tsx @@ -2,13 +2,13 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' -import { OAuthEditModal } from './oauth-edit-modal' +import { OAuthEditModal } from '../oauth-edit-modal' const mockRefetch = vi.fn() const mockUpdate = vi.fn() const mockToast = vi.fn() -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ usePluginStore: () => ({ detail: { id: 'detail-1', @@ -21,7 +21,7 @@ vi.mock('../../store', () => ({ }), })) -vi.mock('../use-subscription-list', () => ({ +vi.mock('../../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-detail-panel/tool-selector/index.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx index f4ed1bcae5..26e4de0fd7 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx @@ -18,9 +18,9 @@ import { ToolItem, ToolSettingsPanel, ToolTrigger, -} from './components' -import { usePluginInstalledCheck, useToolSelectorState } from './hooks' -import ToolSelector from './index' +} from '../components' +import { usePluginInstalledCheck, useToolSelectorState } from '../hooks' +import ToolSelector from '../index' // ==================== Mock Setup ==================== @@ -181,11 +181,11 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ ), })) -vi.mock('../../../readme-panel/entrance', () => ({ +vi.mock('../../../../readme-panel/entrance', () => ({ ReadmeEntrance: () => <div data-testid="readme-entrance" />, })) -vi.mock('./components/reasoning-config-form', () => ({ +vi.mock('../components/reasoning-config-form', () => ({ default: ({ onChange, value, diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx new file mode 100644 index 0000000000..73ebb89e0b --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-base-form.spec.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx new file mode 100644 index 0000000000..20655d0139 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-credentials-form.spec.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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('@/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('common.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('common.operation.save')) + expect(mockOnSaved).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts new file mode 100644 index 0000000000..f3cf0fab54 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts @@ -0,0 +1,63 @@ +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') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts new file mode 100644 index 0000000000..5af624649c --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector-state.spec.ts @@ -0,0 +1,226 @@ +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([]) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx similarity index 89% rename from web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx index 5ae7b62f13..a4414adb59 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx @@ -2,13 +2,7 @@ import type { TriggerEvent } from '@/app/components/plugins/types' import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { EventDetailDrawer } from './event-detail-drawer' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import { EventDetailDrawer } from '../event-detail-drawer' vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useLanguage: () => 'en_US', @@ -121,21 +115,21 @@ describe('EventDetailDrawer', () => { it('should render parameters section', () => { render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.parameters')).toBeInTheDocument() expect(screen.getByText('Parameter 1')).toBeInTheDocument() }) it('should render output section', () => { render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() expect(screen.getByTestId('output-field')).toHaveTextContent('result') }) it('should render back button', () => { render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('detailPanel.operation.back')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.operation.back')).toBeInTheDocument() }) }) @@ -154,7 +148,7 @@ describe('EventDetailDrawer', () => { it('should call onClose when back clicked', () => { render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - fireEvent.click(screen.getByText('detailPanel.operation.back')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.back')) expect(mockOnClose).toHaveBeenCalledTimes(1) }) @@ -165,14 +159,14 @@ describe('EventDetailDrawer', () => { const eventWithNoParams = { ...mockEventInfo, parameters: [] } render(<EventDetailDrawer eventInfo={eventWithNoParams} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.item.noParameters')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.item.noParameters')).toBeInTheDocument() }) it('should handle no output schema', () => { const eventWithNoOutput = { ...mockEventInfo, output_schema: {} } render(<EventDetailDrawer eventInfo={eventWithNoOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() expect(screen.queryByTestId('output-field')).not.toBeInTheDocument() }) }) @@ -185,7 +179,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithNumber} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.number')).toBeInTheDocument() }) it('should display correct type for checkbox', () => { @@ -205,7 +199,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithFile} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument() + expect(screen.getByText('tools.setBuiltInTools.file')).toBeInTheDocument() }) it('should display original type for unknown types', () => { @@ -232,7 +226,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithArrayOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() }) it('should handle nested properties in output schema', () => { @@ -251,7 +245,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithNestedOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() }) it('should handle enum in output schema', () => { @@ -266,7 +260,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithEnumOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() }) it('should handle array type schema', () => { @@ -281,7 +275,7 @@ describe('EventDetailDrawer', () => { } render(<EventDetailDrawer eventInfo={eventWithArrayType} providerInfo={mockProviderInfo} onClose={mockOnClose} />) - expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.output')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx similarity index 88% rename from web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx rename to web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx index 2687319fbc..3ecd248544 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx @@ -1,17 +1,7 @@ import type { TriggerEvent } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TriggerEventsList } from './event-list' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.num !== undefined) - return `${options.num} ${options.event || 'events'}` - return key - }, - }), -})) +import { TriggerEventsList } from '../event-list' vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useLanguage: () => 'en_US', @@ -38,7 +28,7 @@ const mockTriggerEvents = [ let mockDetail: { plugin_id: string, provider: string } | undefined let mockProviderInfo: { events: TriggerEvent[] } | undefined -vi.mock('../store', () => ({ +vi.mock('../../store', () => ({ usePluginStore: (selector: (state: { detail: typeof mockDetail }) => typeof mockDetail) => selector({ detail: mockDetail }), })) @@ -47,7 +37,7 @@ vi.mock('@/service/use-triggers', () => ({ useTriggerProviderInfo: () => ({ data: mockProviderInfo }), })) -vi.mock('./event-detail-drawer', () => ({ +vi.mock('../event-detail-drawer', () => ({ EventDetailDrawer: ({ onClose }: { onClose: () => void }) => ( <div data-testid="event-detail-drawer"> <button data-testid="close-drawer" onClick={onClose}>Close</button> @@ -66,7 +56,7 @@ describe('TriggerEventsList', () => { it('should render event count', () => { render(<TriggerEventsList />) - expect(screen.getByText('1 events.event')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.actionNum:{"num":1,"event":"pluginTrigger.events.event"}')).toBeInTheDocument() }) it('should render event cards', () => { @@ -140,7 +130,7 @@ describe('TriggerEventsList', () => { expect(screen.getByText('Event One')).toBeInTheDocument() expect(screen.getByText('Event Two')).toBeInTheDocument() - expect(screen.getByText('2 events.events')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.events.actionNum:{"num":2,"event":"pluginTrigger.events.events"}')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-item/action.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-item/action.spec.tsx rename to web/app/components/plugins/plugin-item/__tests__/action.spec.tsx index 9969357bb6..8467c983d8 100644 --- a/web/app/components/plugins/plugin-item/action.spec.tsx +++ b/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx @@ -1,12 +1,12 @@ -import type { MetaData, PluginCategoryEnum } from '../types' +import type { MetaData, PluginCategoryEnum } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Toast from '@/app/components/base/toast' // ==================== Imports (after mocks) ==================== -import { PluginSource } from '../types' -import Action from './action' +import { PluginSource } from '../../types' +import Action from '../action' // ==================== Mock Setup ==================== @@ -31,7 +31,7 @@ vi.mock('@/service/plugins', () => ({ })) // Mock GitHub releases hook -vi.mock('../install-plugin/hooks', () => ({ +vi.mock('../../install-plugin/hooks', () => ({ useGitHubReleases: () => ({ fetchReleases: mockFetchReleases, checkForUpdates: mockCheckForUpdates, @@ -51,7 +51,7 @@ vi.mock('@/service/use-plugins', () => ({ })) // Mock PluginInfo component - has complex dependencies (Modal, KeyValueItem) -vi.mock('../plugin-page/plugin-info', () => ({ +vi.mock('../../plugin-page/plugin-info', () => ({ default: ({ repository, release, packageName, onHide }: { repository: string release: string @@ -66,7 +66,7 @@ vi.mock('../plugin-page/plugin-info', () => ({ // Mock Tooltip - uses PortalToFollowElem which requires complex floating UI setup // Simplified mock that just renders children with tooltip content accessible -vi.mock('../../base/tooltip', () => ({ +vi.mock('../../../base/tooltip', () => ({ default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( <div data-testid="tooltip" data-popup-content={popupContent}> {children} @@ -75,7 +75,7 @@ vi.mock('../../base/tooltip', () => ({ })) // Mock Confirm - uses createPortal which has issues in test environment -vi.mock('../../base/confirm', () => ({ +vi.mock('../../../base/confirm', () => ({ default: ({ isShow, title, content, onCancel, onConfirm, isLoading, isDisabled }: { isShow: boolean title: string @@ -875,7 +875,7 @@ describe('Action Component', () => { it('should be wrapped with React.memo', () => { // Assert expect(Action).toBeDefined() - expect((Action as any).$$typeof?.toString()).toContain('Symbol') + expect((Action as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) diff --git a/web/app/components/plugins/plugin-item/index.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx similarity index 94% rename from web/app/components/plugins/plugin-item/index.spec.tsx rename to web/app/components/plugins/plugin-item/__tests__/index.spec.tsx index ae76e64c46..39f3915f99 100644 --- a/web/app/components/plugins/plugin-item/index.spec.tsx +++ b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx @@ -1,27 +1,19 @@ -import type { PluginDeclaration, PluginDetail } from '../types' +import type { PluginDeclaration, PluginDetail } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, PluginSource } from '../types' +import { PluginCategoryEnum, PluginSource } from '../../types' +import PluginItem from '../index' -// ==================== Imports (after mocks) ==================== - -import PluginItem from './index' - -// ==================== Mock Setup ==================== - -// Mock theme hook const mockTheme = vi.fn(() => 'light') vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: mockTheme() }), })) -// Mock i18n render hook const mockGetValueFromI18nObject = vi.fn((obj: Record<string, string>) => obj?.en_US || '') vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: () => mockGetValueFromI18nObject, })) -// Mock categories hook const mockCategoriesMap: Record<string, { name: string, label: string }> = { 'tool': { name: 'tool', label: 'Tools' }, 'model': { name: 'model', label: 'Models' }, @@ -29,18 +21,17 @@ const mockCategoriesMap: Record<string, { name: string, label: string }> = { 'agent-strategy': { name: 'agent-strategy', label: 'Agents' }, 'datasource': { name: 'datasource', label: 'Data Sources' }, } -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useCategories: () => ({ categories: Object.values(mockCategoriesMap), categoriesMap: mockCategoriesMap, }), })) -// Mock plugin page context const mockCurrentPluginID = vi.fn((): string | undefined => undefined) const mockSetCurrentPluginID = vi.fn() -vi.mock('../plugin-page/context', () => ({ - usePluginPageContext: (selector: (v: any) => any) => { +vi.mock('../../plugin-page/context', () => ({ + usePluginPageContext: (selector: (v: Record<string, unknown>) => unknown) => { const context = { currentPluginID: mockCurrentPluginID(), setCurrentPluginID: mockSetCurrentPluginID, @@ -49,13 +40,11 @@ vi.mock('../plugin-page/context', () => ({ }, })) -// Mock refresh plugin list hook const mockRefreshPluginList = vi.fn() vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: mockRefreshPluginList }), })) -// Mock app context const mockLangGeniusVersionInfo = vi.fn(() => ({ current_version: '1.0.0', })) @@ -65,15 +54,13 @@ vi.mock('@/context/app-context', () => ({ }), })) -// Mock global public store const mockEnableMarketplace = vi.fn(() => true) vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (s: any) => any) => + useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) => selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace() } }), })) -// Mock Action component -vi.mock('./action', () => ({ +vi.mock('../action', () => ({ default: ({ onDelete, pluginName }: { onDelete: () => void, pluginName: string }) => ( <div data-testid="plugin-action" data-plugin-name={pluginName}> <button data-testid="delete-button" onClick={onDelete}>Delete</button> @@ -81,20 +68,19 @@ vi.mock('./action', () => ({ ), })) -// Mock child components -vi.mock('../card/base/corner-mark', () => ({ +vi.mock('../../card/base/corner-mark', () => ({ default: ({ text }: { text: string }) => <div data-testid="corner-mark">{text}</div>, })) -vi.mock('../card/base/title', () => ({ +vi.mock('../../card/base/title', () => ({ default: ({ title }: { title: string }) => <div data-testid="plugin-title">{title}</div>, })) -vi.mock('../card/base/description', () => ({ +vi.mock('../../card/base/description', () => ({ default: ({ text }: { text: string }) => <div data-testid="plugin-description">{text}</div>, })) -vi.mock('../card/base/org-info', () => ({ +vi.mock('../../card/base/org-info', () => ({ default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( <div data-testid="org-info" data-org={orgName} data-package={packageName}> {orgName} @@ -104,18 +90,16 @@ vi.mock('../card/base/org-info', () => ({ ), })) -vi.mock('../base/badges/verified', () => ({ +vi.mock('../../base/badges/verified', () => ({ default: ({ text }: { text: string }) => <div data-testid="verified-badge">{text}</div>, })) -vi.mock('../../base/badge', () => ({ +vi.mock('../../../base/badge', () => ({ default: ({ text, hasRedCornerMark }: { text: string, hasRedCornerMark?: boolean }) => ( <div data-testid="version-badge" data-has-update={hasRedCornerMark}>{text}</div> ), })) -// ==================== Test Utilities ==================== - const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({ plugin_unique_identifier: 'test-plugin-id', version: '1.0.0', @@ -124,13 +108,13 @@ const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): Pl icon_dark: 'test-icon-dark.png', name: 'test-plugin', category: PluginCategoryEnum.tool, - label: { en_US: 'Test Plugin' } as any, - description: { en_US: 'Test plugin description' } as any, + label: { en_US: 'Test Plugin' } as unknown as PluginDeclaration['label'], + description: { en_US: 'Test plugin description' } as unknown as PluginDeclaration['description'], created_at: '2024-01-01', resource: null, plugins: null, verified: false, - endpoint: {} as any, + endpoint: {} as unknown as PluginDeclaration['endpoint'], model: null, tags: [], agent_strategy: null, @@ -138,7 +122,7 @@ const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): Pl version: '1.0.0', minimum_dify_version: '0.5.0', }, - trigger: {} as any, + trigger: {} as unknown as PluginDeclaration['trigger'], ...overrides, }) @@ -169,8 +153,6 @@ const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail ...overrides, }) -// ==================== Tests ==================== - describe('PluginItem', () => { beforeEach(() => { vi.clearAllMocks() @@ -181,7 +163,6 @@ describe('PluginItem', () => { mockGetValueFromI18nObject.mockImplementation((obj: Record<string, string>) => obj?.en_US || '') }) - // ==================== Rendering Tests ==================== describe('Rendering', () => { it('should render plugin item with basic info', () => { // Arrange @@ -235,7 +216,6 @@ describe('PluginItem', () => { }) }) - // ==================== Plugin Sources Tests ==================== describe('Plugin Sources', () => { it('should render GitHub source with repo link', () => { // Arrange @@ -333,7 +313,6 @@ describe('PluginItem', () => { }) }) - // ==================== Extension Category Tests ==================== describe('Extension Category', () => { it('should show endpoints info for extension category', () => { // Arrange @@ -364,7 +343,6 @@ describe('PluginItem', () => { }) }) - // ==================== Version Compatibility Tests ==================== describe('Version Compatibility', () => { it('should show warning icon when Dify version is not compatible', () => { // Arrange @@ -430,7 +408,6 @@ describe('PluginItem', () => { }) }) - // ==================== Deprecated Plugin Tests ==================== describe('Deprecated Plugin', () => { it('should show deprecated indicator for deprecated marketplace plugin', () => { // Arrange @@ -842,7 +819,6 @@ describe('PluginItem', () => { }) }) - // ==================== Edge Cases ==================== describe('Edge Cases', () => { it('should handle empty icon gracefully', () => { // Arrange @@ -900,7 +876,7 @@ describe('PluginItem', () => { const plugin = createPluginDetail({ source: PluginSource.marketplace, version: '1.0.0', - latest_version: null as any, + latest_version: null as unknown as string, }) // Act @@ -959,7 +935,6 @@ describe('PluginItem', () => { }) }) - // ==================== Callback Stability Tests ==================== describe('Callback Stability', () => { it('should have stable handleDelete callback', () => { // Arrange @@ -1002,7 +977,6 @@ describe('PluginItem', () => { }) }) - // ==================== React.memo Tests ==================== describe('React.memo Behavior', () => { it('should be wrapped with React.memo', () => { // Arrange & Assert @@ -1010,7 +984,7 @@ describe('PluginItem', () => { // We can verify by checking the displayName or type expect(PluginItem).toBeDefined() // React.memo components have a $$typeof property - expect((PluginItem as any).$$typeof?.toString()).toContain('Symbol') + expect((PluginItem as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) diff --git a/web/app/components/plugins/plugin-mutation-model/index.spec.tsx b/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx similarity index 92% rename from web/app/components/plugins/plugin-mutation-model/index.spec.tsx rename to web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx index 98be2e4373..d36cf12f11 100644 --- a/web/app/components/plugins/plugin-mutation-model/index.spec.tsx +++ b/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx @@ -1,32 +1,24 @@ -import type { Plugin } from '../types' +import type { Plugin } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum } from '../types' -import PluginMutationModal from './index' +import { PluginCategoryEnum } from '../../types' +import PluginMutationModal from '../index' -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock useTheme hook vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), })) -// Mock i18n-config vi.mock('@/i18n-config', () => ({ renderI18nObject: (obj: Record<string, string>, locale: string) => { return obj?.[locale] || obj?.['en-US'] || '' }, })) -// Mock i18n-config/language vi.mock('@/i18n-config/language', () => ({ getLanguage: (locale: string) => locale || 'en-US', })) -// Mock useCategories hook const mockCategoriesMap: Record<string, { label: string }> = { 'tool': { label: 'Tool' }, 'model': { label: 'Model' }, @@ -37,18 +29,16 @@ const mockCategoriesMap: Record<string, { label: string }> = { 'bundle': { label: 'Bundle' }, } -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useCategories: () => ({ categoriesMap: mockCategoriesMap, }), })) -// Mock formatNumber utility vi.mock('@/utils/format', () => ({ formatNumber: (num: number) => num.toLocaleString(), })) -// Mock shouldUseMcpIcon utility vi.mock('@/utils/mcp', () => ({ shouldUseMcpIcon: (src: unknown) => typeof src === 'object' @@ -56,7 +46,6 @@ vi.mock('@/utils/mcp', () => ({ && (src as { content?: string })?.content === '🔗', })) -// Mock AppIcon component vi.mock('@/app/components/base/app-icon', () => ({ default: ({ icon, @@ -83,7 +72,6 @@ vi.mock('@/app/components/base/app-icon', () => ({ ), })) -// Mock Mcp icon component vi.mock('@/app/components/base/icons/src/vender/other', () => ({ Mcp: ({ className }: { className?: string }) => ( <div data-testid="mcp-icon" className={className}> @@ -97,8 +85,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({ ), })) -// Mock LeftCorner icon component -vi.mock('../../base/icons/src/vender/plugin', () => ({ +vi.mock('../../../base/icons/src/vender/plugin', () => ({ LeftCorner: ({ className }: { className?: string }) => ( <div data-testid="left-corner" className={className}> LeftCorner @@ -106,8 +93,7 @@ vi.mock('../../base/icons/src/vender/plugin', () => ({ ), })) -// Mock Partner badge -vi.mock('../base/badges/partner', () => ({ +vi.mock('../../base/badges/partner', () => ({ default: ({ className, text }: { className?: string, text?: string }) => ( <div data-testid="partner-badge" className={className} title={text}> Partner @@ -115,8 +101,7 @@ vi.mock('../base/badges/partner', () => ({ ), })) -// Mock Verified badge -vi.mock('../base/badges/verified', () => ({ +vi.mock('../../base/badges/verified', () => ({ default: ({ className, text }: { className?: string, text?: string }) => ( <div data-testid="verified-badge" className={className} title={text}> Verified @@ -124,36 +109,6 @@ vi.mock('../base/badges/verified', () => ({ ), })) -// Mock Remix icons -vi.mock('@remixicon/react', () => ({ - RiCheckLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-check-line" className={className}> - ✓ - </span> - ), - RiCloseLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-close-line" className={className}> - ✕ - </span> - ), - RiInstallLine: ({ className }: { className?: string }) => ( - <span data-testid="ri-install-line" className={className}> - ↓ - </span> - ), - RiAlertFill: ({ className }: { className?: string }) => ( - <span data-testid="ri-alert-fill" className={className}> - ⚠ - </span> - ), - RiLoader2Line: ({ className }: { className?: string }) => ( - <span data-testid="ri-loader-line" className={className}> - ⟳ - </span> - ), -})) - -// Mock Skeleton components vi.mock('@/app/components/base/skeleton', () => ({ SkeletonContainer: ({ children }: { children: React.ReactNode }) => ( <div data-testid="skeleton-container">{children}</div> @@ -330,8 +285,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - // The modal should have a close button - expect(screen.getByTestId('ri-close-line')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) @@ -465,9 +419,8 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - // Find the close icon - the Modal component handles the onClose callback - const closeIcon = screen.getByTestId('ri-close-line') - expect(closeIcon).toBeInTheDocument() + const dialog = screen.getByRole('dialog') + expect(dialog).toBeInTheDocument() }) it('should not call mutate when button is disabled during pending', () => { @@ -563,9 +516,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - // The Card component should receive installed=true - // This will show a check icon - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() }) }) @@ -577,8 +528,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - // The check icon should not be present (installed=false) - expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).not.toBeInTheDocument() }) }) @@ -593,7 +543,7 @@ describe('PluginMutationModal', () => { expect( screen.queryByRole('button', { name: /Cancel/i }), ).not.toBeInTheDocument() - expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).not.toBeInTheDocument() }) it('should handle isPending=false and isSuccess=true', () => { @@ -606,7 +556,7 @@ describe('PluginMutationModal', () => { expect( screen.getByRole('button', { name: /Cancel/i }), ).toBeInTheDocument() - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() }) it('should handle both isPending=true and isSuccess=true', () => { @@ -619,7 +569,7 @@ describe('PluginMutationModal', () => { expect( screen.queryByRole('button', { name: /Cancel/i }), ).not.toBeInTheDocument() - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() }) }) }) @@ -710,8 +660,8 @@ describe('PluginMutationModal', () => { it('should have displayName set', () => { // The component sets displayName = 'PluginMutationModal' const displayName - = (PluginMutationModal as any).type?.displayName - || (PluginMutationModal as any).displayName + = (PluginMutationModal as unknown as { type?: { displayName?: string }, displayName?: string }).type?.displayName + || (PluginMutationModal as unknown as { displayName?: string }).displayName expect(displayName).toBe('PluginMutationModal') }) @@ -901,8 +851,7 @@ describe('PluginMutationModal', () => { render(<PluginMutationModal {...props} />) - // Close icon should be present - expect(screen.getByTestId('ri-close-line')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) @@ -1118,8 +1067,7 @@ describe('PluginMutationModal', () => { />, ) - // Should show success state - expect(screen.getByTestId('ri-check-line')).toBeInTheDocument() + expect(document.querySelector('.bg-state-success-solid')).toBeInTheDocument() }) it('should handle plugin prop changes', () => { diff --git a/web/app/components/plugins/plugin-page/context.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-page/context.spec.tsx rename to web/app/components/plugins/plugin-page/__tests__/context.spec.tsx index ea52ae1dbd..4dd23f53f1 100644 --- a/web/app/components/plugins/plugin-page/context.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Import mocks import { useGlobalPublicStore } from '@/context/global-public-context' -import { PluginPageContext, PluginPageContextProvider, usePluginPageContext } from './context' +import { PluginPageContext, PluginPageContextProvider, usePluginPageContext } from '../context' // Mock dependencies vi.mock('nuqs', () => ({ @@ -14,7 +14,7 @@ vi.mock('@/context/global-public-context', () => ({ useGlobalPublicStore: vi.fn(), })) -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ PLUGIN_PAGE_TABS_MAP: { plugins: 'plugins', marketplace: 'discover', diff --git a/web/app/components/plugins/plugin-page/index.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/plugins/plugin-page/index.spec.tsx rename to web/app/components/plugins/plugin-page/__tests__/index.spec.tsx index 9b7ada2a87..be9f0b1858 100644 --- a/web/app/components/plugins/plugin-page/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { PluginPageProps } from './index' +import type { PluginPageProps } from '../index' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useQueryState } from 'nuqs' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { usePluginInstallation } from '@/hooks/use-query-params' // Import mocked modules for assertions import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' -import PluginPageWithContext from './index' +import PluginPageWithContext from '../index' // Mock external dependencies vi.mock('@/service/plugins', () => ({ @@ -83,15 +83,15 @@ vi.mock('nuqs', () => ({ useQueryState: vi.fn(() => ['plugins', vi.fn()]), })) -vi.mock('./plugin-tasks', () => ({ +vi.mock('../plugin-tasks', () => ({ default: () => <div data-testid="plugin-tasks">PluginTasks</div>, })) -vi.mock('./debug-info', () => ({ +vi.mock('../debug-info', () => ({ default: () => <div data-testid="debug-info">DebugInfo</div>, })) -vi.mock('./install-plugin-dropdown', () => ({ +vi.mock('../install-plugin-dropdown', () => ({ default: ({ onSwitchToMarketplaceTab }: { onSwitchToMarketplaceTab: () => void }) => ( <button data-testid="install-dropdown" onClick={onSwitchToMarketplaceTab}> Install @@ -99,7 +99,7 @@ vi.mock('./install-plugin-dropdown', () => ({ ), })) -vi.mock('../install-plugin/install-from-local-package', () => ({ +vi.mock('../../install-plugin/install-from-local-package', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="install-local-modal"> <button onClick={onClose}>Close</button> @@ -107,7 +107,7 @@ vi.mock('../install-plugin/install-from-local-package', () => ({ ), })) -vi.mock('../install-plugin/install-from-marketplace', () => ({ +vi.mock('../../install-plugin/install-from-marketplace', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="install-marketplace-modal"> <button onClick={onClose}>Close</button> diff --git a/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx new file mode 100644 index 0000000000..e95f4686f8 --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/plugin-info.spec.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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('plugin.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) + }) +}) diff --git a/web/app/components/plugins/plugin-page/use-reference-setting.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts similarity index 97% rename from web/app/components/plugins/plugin-page/use-reference-setting.spec.ts rename to web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts index 9f64d3fac5..d43e0a7b97 100644 --- a/web/app/components/plugins/plugin-page/use-reference-setting.spec.ts +++ b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts @@ -5,16 +5,9 @@ import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins' -import Toast from '../../base/toast' -import { PermissionType } from '../types' -import useReferenceSetting, { useCanInstallPluginFromMarketplace } from './use-reference-setting' - -// Mock dependencies -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import Toast from '../../../base/toast' +import { PermissionType } from '../../types' +import useReferenceSetting, { useCanInstallPluginFromMarketplace } from '../use-reference-setting' vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), @@ -30,7 +23,7 @@ vi.mock('@/service/use-plugins', () => ({ useInvalidateReferenceSettings: vi.fn(), })) -vi.mock('../../base/toast', () => ({ +vi.mock('../../../base/toast', () => ({ default: { notify: vi.fn(), }, @@ -235,7 +228,7 @@ describe('useReferenceSetting Hook', () => { expect(mockInvalidate).toHaveBeenCalled() expect(Toast.notify).toHaveBeenCalledWith({ type: 'success', - message: 'api.actionSuccess', + message: 'common.api.actionSuccess', }) }) }) diff --git a/web/app/components/plugins/plugin-page/use-uploader.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-uploader.spec.ts similarity index 99% rename from web/app/components/plugins/plugin-page/use-uploader.spec.ts rename to web/app/components/plugins/plugin-page/__tests__/use-uploader.spec.ts index fa9463b7c0..3936117ead 100644 --- a/web/app/components/plugins/plugin-page/use-uploader.spec.ts +++ b/web/app/components/plugins/plugin-page/__tests__/use-uploader.spec.ts @@ -1,7 +1,7 @@ import type { RefObject } from 'react' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useUploader } from './use-uploader' +import { useUploader } from '../use-uploader' describe('useUploader Hook', () => { let mockContainerRef: RefObject<HTMLDivElement | null> diff --git a/web/app/components/plugins/plugin-page/empty/index.spec.tsx b/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/plugins/plugin-page/empty/index.spec.tsx rename to web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx index 51d4af919d..933814eca5 100644 --- a/web/app/components/plugins/plugin-page/empty/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { FilterState } from '../filter-management' +import type { FilterState } from '../../filter-management' import type { SystemFeatures } from '@/types/feature' import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -6,7 +6,7 @@ import { defaultSystemFeatures, InstallationScope } from '@/types/feature' // ==================== Imports (after mocks) ==================== -import Empty from './index' +import Empty from '../index' // ==================== Mock Setup ==================== @@ -15,7 +15,6 @@ const { mockSetActiveTab, mockUseInstalledPluginList, mockState, - stableT, } = vi.hoisted(() => { const state = { filters: { @@ -32,20 +31,16 @@ const { } as Partial<SystemFeatures>, pluginList: { plugins: [] as Array<{ id: string }> } as { plugins: Array<{ id: string }> } | undefined, } - // Stable t function to prevent infinite re-renders - // The component's useEffect and useMemo depend on t - const t = (key: string) => key return { mockSetActiveTab: vi.fn(), mockUseInstalledPluginList: vi.fn(() => ({ data: state.pluginList })), mockState: state, - stableT: t, } }) // Mock plugin page context -vi.mock('../context', () => ({ - usePluginPageContext: (selector: (value: any) => any) => { +vi.mock('../../context', () => ({ + usePluginPageContext: (selector: (value: Record<string, unknown>) => unknown) => { const contextValue = { filters: mockState.filters, setActiveTab: mockSetActiveTab, @@ -56,7 +51,7 @@ vi.mock('../context', () => ({ // Mock global public store (Zustand store) vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: any) => any) => { + useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => { return selector({ systemFeatures: { ...defaultSystemFeatures, @@ -92,22 +87,10 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () })) // Mock Line component -vi.mock('../../marketplace/empty/line', () => ({ +vi.mock('../../../marketplace/empty/line', () => ({ default: ({ className }: { className?: string }) => <div data-testid="line-component" className={className} />, })) -// Override react-i18next with stable t function reference to prevent infinite re-renders -// The component's useEffect and useMemo depend on t, so it MUST be stable -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: stableT, - i18n: { - language: 'en', - changeLanguage: vi.fn(), - }, - }), -})) - // ==================== Test Utilities ==================== const resetMockState = () => { @@ -191,7 +174,7 @@ describe('Empty Component', () => { await flushEffects() // Assert - expect(screen.getByText('list.noInstalled')).toBeInTheDocument() + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() }) it('should display "notFound" text when filters are active with plugins', async () => { @@ -202,19 +185,19 @@ describe('Empty Component', () => { setMockFilters({ categories: ['model'] }) const { rerender } = render(<Empty />) await flushEffects() - expect(screen.getByText('list.notFound')).toBeInTheDocument() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() // Test tags filter setMockFilters({ categories: [], tags: ['tag1'] }) rerender(<Empty />) await flushEffects() - expect(screen.getByText('list.notFound')).toBeInTheDocument() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() // Test searchQuery filter setMockFilters({ tags: [], searchQuery: 'test query' }) rerender(<Empty />) await flushEffects() - expect(screen.getByText('list.notFound')).toBeInTheDocument() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() }) it('should prioritize "noInstalled" over "notFound" when no plugins exist', async () => { @@ -227,7 +210,7 @@ describe('Empty Component', () => { await flushEffects() // Assert - expect(screen.getByText('list.noInstalled')).toBeInTheDocument() + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() }) }) @@ -250,15 +233,15 @@ describe('Empty Component', () => { // Assert const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(3) - expect(screen.getByText('source.marketplace')).toBeInTheDocument() - expect(screen.getByText('source.github')).toBeInTheDocument() - expect(screen.getByText('source.local')).toBeInTheDocument() + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + expect(screen.getByText('plugin.source.local')).toBeInTheDocument() // Verify button order const buttonTexts = buttons.map(btn => btn.textContent) - expect(buttonTexts[0]).toContain('source.marketplace') - expect(buttonTexts[1]).toContain('source.github') - expect(buttonTexts[2]).toContain('source.local') + expect(buttonTexts[0]).toContain('plugin.source.marketplace') + expect(buttonTexts[1]).toContain('plugin.source.github') + expect(buttonTexts[2]).toContain('plugin.source.local') }) it('should render only marketplace method when restricted to marketplace only', async () => { @@ -278,9 +261,9 @@ describe('Empty Component', () => { // Assert const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(1) - expect(screen.getByText('source.marketplace')).toBeInTheDocument() - expect(screen.queryByText('source.github')).not.toBeInTheDocument() - expect(screen.queryByText('source.local')).not.toBeInTheDocument() + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.source.local')).not.toBeInTheDocument() }) it('should render github and local methods when marketplace is disabled', async () => { @@ -300,9 +283,9 @@ describe('Empty Component', () => { // Assert const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(2) - expect(screen.queryByText('source.marketplace')).not.toBeInTheDocument() - expect(screen.getByText('source.github')).toBeInTheDocument() - expect(screen.getByText('source.local')).toBeInTheDocument() + expect(screen.queryByText('plugin.source.marketplace')).not.toBeInTheDocument() + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + expect(screen.getByText('plugin.source.local')).toBeInTheDocument() }) it('should render no methods when marketplace disabled and restricted', async () => { @@ -333,7 +316,7 @@ describe('Empty Component', () => { await flushEffects() // Act - fireEvent.click(screen.getByText('source.marketplace')) + fireEvent.click(screen.getByText('plugin.source.marketplace')) // Assert expect(mockSetActiveTab).toHaveBeenCalledWith('discover') @@ -348,7 +331,7 @@ describe('Empty Component', () => { expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() // Act - open modal - fireEvent.click(screen.getByText('source.github')) + fireEvent.click(screen.getByText('plugin.source.github')) // Assert - modal is open expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() @@ -368,7 +351,7 @@ describe('Empty Component', () => { const clickSpy = vi.spyOn(fileInput, 'click') // Act - fireEvent.click(screen.getByText('source.local')) + fireEvent.click(screen.getByText('plugin.source.local')) // Assert expect(clickSpy).toHaveBeenCalled() @@ -422,13 +405,13 @@ describe('Empty Component', () => { await flushEffects() // Act - Open, close, and reopen GitHub modal - fireEvent.click(screen.getByText('source.github')) + fireEvent.click(screen.getByText('plugin.source.github')) expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() fireEvent.click(screen.getByTestId('github-modal-close')) expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() - fireEvent.click(screen.getByText('source.github')) + fireEvent.click(screen.getByText('plugin.source.github')) expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() }) @@ -480,7 +463,7 @@ describe('Empty Component', () => { render(<Empty />) await flushEffects() expect(screen.getAllByRole('button')).toHaveLength(1) - expect(screen.getByText('source.marketplace')).toBeInTheDocument() + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() }) it('should render correct text based on plugin list and filters', async () => { @@ -490,7 +473,7 @@ describe('Empty Component', () => { const { unmount: unmount1 } = render(<Empty />) await flushEffects() - expect(screen.getByText('list.noInstalled')).toBeInTheDocument() + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() unmount1() // Test 2: notFound when filters are active with plugins @@ -499,7 +482,7 @@ describe('Empty Component', () => { render(<Empty />) await flushEffects() - expect(screen.getByText('list.notFound')).toBeInTheDocument() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() }) }) @@ -529,8 +512,8 @@ describe('Empty Component', () => { it('should be wrapped with React.memo and have displayName', () => { // Assert expect(Empty).toBeDefined() - expect((Empty as any).$$typeof?.toString()).toContain('Symbol') - expect((Empty as any).displayName || (Empty as any).type?.displayName).toBeDefined() + expect((Empty as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') + expect((Empty as unknown as { displayName?: string, type?: { displayName?: string } }).displayName || (Empty as unknown as { type?: { displayName?: string } }).type?.displayName).toBeDefined() }) }) @@ -542,7 +525,7 @@ describe('Empty Component', () => { await flushEffects() // Test GitHub modal onSuccess - fireEvent.click(screen.getByText('source.github')) + fireEvent.click(screen.getByText('plugin.source.github')) fireEvent.click(screen.getByTestId('github-modal-success')) expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() @@ -570,12 +553,12 @@ describe('Empty Component', () => { expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() // Open GitHub modal - only GitHub modal visible - fireEvent.click(screen.getByText('source.github')) + fireEvent.click(screen.getByText('plugin.source.github')) expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() // Click local button - triggers file input, no modal yet (no file selected) - fireEvent.click(screen.getByText('source.local')) + fireEvent.click(screen.getByText('plugin.source.local')) // GitHub modal should still be visible, local modal requires file selection expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() }) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx new file mode 100644 index 0000000000..6c20bb0b28 --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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('plugin.allCategories')).toBeInTheDocument() + }) + + it('should show selected category labels', () => { + render(<CategoriesFilter value={['tool']} onChange={vi.fn()} />) + + 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 clear all selections when clear button clicked', () => { + const mockOnChange = vi.fn() + render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />) + + const trigger = screen.getByTestId('portal-trigger') + const clearSvg = trigger.querySelector('svg') + fireEvent.click(clearSvg!) + expect(mockOnChange).toHaveBeenCalledWith([]) + }) + + 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} />) + + const toolElements = screen.getAllByText('Tool') + fireEvent.click(toolElements[toolElements.length - 1]) + expect(mockOnChange).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/plugins/plugin-page/filter-management/index.spec.tsx rename to web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx index b942a360b0..95f0c5c120 100644 --- a/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx @@ -1,16 +1,16 @@ -import type { Category, Tag } from './constant' -import type { FilterState } from './index' +import type { Category, Tag } from '../constant' +import type { FilterState } from '../index' import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // ==================== Imports (after mocks) ==================== -import CategoriesFilter from './category-filter' +import CategoriesFilter from '../category-filter' // Import real components -import FilterManagement from './index' -import SearchBox from './search-box' -import { useStore } from './store' -import TagFilter from './tag-filter' +import FilterManagement from '../index' +import SearchBox from '../search-box' +import { useStore } from '../store' +import TagFilter from '../tag-filter' // ==================== Mock Setup ==================== @@ -21,7 +21,7 @@ let mockInitFilters: FilterState = { searchQuery: '', } -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ usePluginPageContext: (selector: (v: { filters: FilterState }) => FilterState) => selector({ filters: mockInitFilters }), })) @@ -56,7 +56,7 @@ const mockTagsMap: Record<string, { name: string, label: string }> = { image: { name: 'image', label: 'Image' }, } -vi.mock('../../hooks', () => ({ +vi.mock('../../../hooks', () => ({ useCategories: () => ({ categories: mockCategories, categoriesMap: mockCategoriesMap, diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/search-box.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/search-box.spec.tsx new file mode 100644 index 0000000000..26736227d5 --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/search-box.spec.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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', 'plugin.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') + }) +}) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/store.spec.ts b/web/app/components/plugins/plugin-page/filter-management/__tests__/store.spec.ts new file mode 100644 index 0000000000..26316e78e8 --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/store.spec.ts @@ -0,0 +1,85 @@ +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() + }) +}) diff --git a/web/app/components/plugins/plugin-page/list/index.spec.tsx b/web/app/components/plugins/plugin-page/list/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/plugins/plugin-page/list/index.spec.tsx rename to web/app/components/plugins/plugin-page/list/__tests__/index.spec.tsx index 7709585e8e..c6326461d4 100644 --- a/web/app/components/plugins/plugin-page/list/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/list/__tests__/index.spec.tsx @@ -1,16 +1,16 @@ -import type { PluginDeclaration, PluginDetail } from '../../types' +import type { PluginDeclaration, PluginDetail } from '../../../types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, PluginSource } from '../../types' +import { PluginCategoryEnum, PluginSource } from '../../../types' // ==================== Imports (after mocks) ==================== -import PluginList from './index' +import PluginList from '../index' // ==================== Mock Setup ==================== // Mock PluginItem component to avoid complex dependency chain -vi.mock('../../plugin-item', () => ({ +vi.mock('../../../plugin-item', () => ({ default: ({ plugin }: { plugin: PluginDetail }) => ( <div data-testid="plugin-item" @@ -35,13 +35,13 @@ const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): Pl icon_dark: 'test-icon-dark.png', name: 'test-plugin', category: PluginCategoryEnum.tool, - label: { en_US: 'Test Plugin' } as any, - description: { en_US: 'Test plugin description' } as any, + label: { en_US: 'Test Plugin' } as unknown as PluginDeclaration['label'], + description: { en_US: 'Test plugin description' } as unknown as PluginDeclaration['description'], created_at: '2024-01-01', resource: null, plugins: null, verified: false, - endpoint: {} as any, + endpoint: {} as unknown as PluginDeclaration['endpoint'], model: null, tags: [], agent_strategy: null, @@ -49,7 +49,7 @@ const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): Pl version: '1.0.0', minimum_dify_version: '0.5.0', }, - trigger: {} as any, + trigger: {} as unknown as PluginDeclaration['trigger'], ...overrides, }) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..3d5269593d --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/hooks.spec.ts @@ -0,0 +1,77 @@ +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() + }) +}) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx rename to web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx index 32892cbe28..85db106646 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx @@ -4,11 +4,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { TaskStatus } from '@/app/components/plugins/types' // Import mocked modules import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plugins' -import PluginTaskList from './components/plugin-task-list' -import TaskStatusIndicator from './components/task-status-indicator' -import { usePluginTaskStatus } from './hooks' +import PluginTaskList from '../components/plugin-task-list' +import TaskStatusIndicator from '../components/task-status-indicator' +import { usePluginTaskStatus } from '../hooks' -import PluginTasks from './index' +import PluginTasks from '../index' // Mock external dependencies vi.mock('@/service/use-plugins', () => ({ @@ -51,18 +51,15 @@ const setupMocks = (plugins: PluginStatus[] = []) => { ? [{ id: 'task-1', plugins, created_at: '', updated_at: '', status: 'running', total_plugins: plugins.length, completed_plugins: 0 }] : [], handleRefetch: mockHandleRefetch, - } as any) + } as unknown as ReturnType<typeof usePluginTaskList>) vi.mocked(useMutationClearTaskPlugin).mockReturnValue({ mutateAsync: mockMutateAsync, - } as any) + } as unknown as ReturnType<typeof useMutationClearTaskPlugin>) return { mockMutateAsync, mockHandleRefetch } } -// ============================================================================ -// usePluginTaskStatus Hook Tests -// ============================================================================ describe('usePluginTaskStatus Hook', () => { beforeEach(() => { vi.clearAllMocks() @@ -413,9 +410,6 @@ describe('TaskStatusIndicator Component', () => { }) }) -// ============================================================================ -// PluginTaskList Component Tests -// ============================================================================ describe('PluginTaskList Component', () => { const defaultProps = { runningPlugins: [] as PluginStatus[], diff --git a/web/app/components/plugins/readme-panel/__tests__/constants.spec.ts b/web/app/components/plugins/readme-panel/__tests__/constants.spec.ts new file mode 100644 index 0000000000..372211cc77 --- /dev/null +++ b/web/app/components/plugins/readme-panel/__tests__/constants.spec.ts @@ -0,0 +1,20 @@ +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') + }) +}) diff --git a/web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx b/web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx new file mode 100644 index 0000000000..f1e3c548de --- /dev/null +++ b/web/app/components/plugins/readme-panel/__tests__/entrance.spec.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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.getByRole('button')).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('') + }) +}) diff --git a/web/app/components/plugins/readme-panel/index.spec.tsx b/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx similarity index 67% rename from web/app/components/plugins/readme-panel/index.spec.tsx rename to web/app/components/plugins/readme-panel/__tests__/index.spec.tsx index 340fe0abcd..d52a22cb61 100644 --- a/web/app/components/plugins/readme-panel/index.spec.tsx +++ b/web/app/components/plugins/readme-panel/__tests__/index.spec.tsx @@ -1,12 +1,11 @@ -import type { PluginDetail } from '../types' +import type { PluginDetail } from '../../types' 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' +import { PluginCategoryEnum, PluginSource } from '../../types' +import { ReadmeEntrance } from '../entrance' +import ReadmePanel from '../index' +import { ReadmeShowType, useReadmePanelStore } from '../store' // ================================ // Mock external dependencies only @@ -25,7 +24,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) // Mock DetailHeader component (complex component with many dependencies) -vi.mock('../plugin-detail-panel/detail-header', () => ({ +vi.mock('../../plugin-detail-panel/detail-header', () => ({ default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => ( <div data-testid="detail-header" data-is-readme-view={isReadmeView}> {detail.name} @@ -115,289 +114,9 @@ const renderWithQueryClient = (ui: React.ReactElement) => { ) } -// ================================ -// 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') - }) - }) -}) +// 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 // ================================ // ReadmePanel Component Tests diff --git a/web/app/components/plugins/readme-panel/__tests__/store.spec.ts b/web/app/components/plugins/readme-panel/__tests__/store.spec.ts new file mode 100644 index 0000000000..a349659f42 --- /dev/null +++ b/web/app/components/plugins/readme-panel/__tests__/store.spec.ts @@ -0,0 +1,54 @@ +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) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx similarity index 75% rename from web/app/components/plugins/reference-setting-modal/index.spec.tsx rename to web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx index 43056b4e86..91986b4b35 100644 --- a/web/app/components/plugins/reference-setting-modal/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx @@ -1,37 +1,11 @@ -import type { AutoUpdateConfig } from './auto-update-setting/types' +import type { AutoUpdateConfig } from '../auto-update-setting/types' import type { Permissions, ReferenceSetting } from '@/app/components/plugins/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' 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 -// ================================ - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const translations: Record<string, string> = { - 'privilege.title': 'Plugin Permissions', - 'privilege.whoCanInstall': 'Who can install plugins', - 'privilege.whoCanDebug': 'Who can debug plugins', - 'privilege.everyone': 'Everyone', - 'privilege.admins': 'Admins Only', - 'privilege.noone': 'No One', - 'operation.cancel': 'Cancel', - 'operation.save': 'Save', - 'autoUpdate.updateSettings': 'Update Settings', - } - const fullKey = options?.ns ? `${options.ns}.${key}` : key - return translations[fullKey] || translations[key] || key - }, - }), -})) +import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../auto-update-setting/types' +import ReferenceSettingModal from '../index' // Mock global public store const mockSystemFeatures = { enable_marketplace: true } @@ -86,7 +60,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({ // Mock AutoUpdateSetting component const mockAutoUpdateSettingOnChange = vi.fn() -vi.mock('./auto-update-setting', () => ({ +vi.mock('../auto-update-setting', () => ({ default: ({ payload, onChange }: { payload: AutoUpdateConfig onChange: (payload: AutoUpdateConfig) => void @@ -111,7 +85,7 @@ vi.mock('./auto-update-setting', () => ({ })) // Mock config default value -vi.mock('./auto-update-setting/config', () => ({ +vi.mock('../auto-update-setting/config', () => ({ defaultValue: { strategy_setting: AUTO_UPDATE_STRATEGY.disabled, upgrade_time_of_day: 0, @@ -156,153 +130,7 @@ describe('reference-setting-modal', () => { mockSystemFeatures.enable_marketplace = true }) - // ============================================================ - // 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() - }) - }) - }) + // Label component tests moved to label.spec.tsx // ============================================================ // ReferenceSettingModal (PluginSettingModal) Component Tests @@ -320,7 +148,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() }) it('should render install permission section', () => { @@ -328,7 +156,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - expect(screen.getByText('Who can install plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.whoCanInstall')).toBeInTheDocument() }) it('should render debug permission section', () => { @@ -336,7 +164,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - expect(screen.getByText('Who can debug plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.whoCanDebug')).toBeInTheDocument() }) it('should render all permission options for install', () => { @@ -352,8 +180,8 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - expect(screen.getByText('Cancel')).toBeInTheDocument() - expect(screen.getByText('Save')).toBeInTheDocument() + expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() + expect(screen.getByText('common.operation.save')).toBeInTheDocument() }) it('should render AutoUpdateSetting when marketplace is enabled', () => { @@ -401,11 +229,11 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Assert - admin option should be selected for install (first one) - const adminOptions = screen.getAllByTestId('option-card-admins-only') + const adminOptions = screen.getAllByTestId('option-card-plugin.privilege.admins') expect(adminOptions[0]).toHaveAttribute('aria-pressed', 'true') // Install permission // Assert - noOne option should be selected for debug (second one) - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') expect(noOneOptions[1]).toHaveAttribute('aria-pressed', 'true') // Debug permission }) @@ -414,7 +242,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Act - click on "No One" for install permission - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') fireEvent.click(noOneOptions[0]) // First one is for install permission // Assert - the option should now be selected @@ -440,7 +268,7 @@ describe('reference-setting-modal', () => { // Arrange const payload = { permission: createMockPermissions(), - auto_upgrade: undefined as any, + auto_upgrade: undefined as unknown as AutoUpdateConfig, } // Act @@ -458,7 +286,7 @@ describe('reference-setting-modal', () => { // Act render(<ReferenceSettingModal {...defaultProps} onHide={onHide} />) - fireEvent.click(screen.getByText('Cancel')) + fireEvent.click(screen.getByText('common.operation.cancel')) // Assert expect(onHide).toHaveBeenCalledTimes(1) @@ -483,7 +311,7 @@ describe('reference-setting-modal', () => { // Act render(<ReferenceSettingModal {...defaultProps} onSave={onSave} onHide={onHide} />) - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { @@ -501,7 +329,7 @@ describe('reference-setting-modal', () => { // Act render(<ReferenceSettingModal {...defaultProps} onSave={onSave} onHide={onHide} />) - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { @@ -522,7 +350,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Click Everyone for install permission - const everyoneOptions = screen.getAllByTestId('option-card-everyone') + const everyoneOptions = screen.getAllByTestId('option-card-plugin.privilege.everyone') fireEvent.click(everyoneOptions[0]) // Assert @@ -542,7 +370,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Click Admins Only for debug permission (second set of options) - const adminOptions = screen.getAllByTestId('option-card-admins-only') + const adminOptions = screen.getAllByTestId('option-card-plugin.privilege.admins') fireEvent.click(adminOptions[1]) // Second one is for debug permission // Assert @@ -560,7 +388,7 @@ describe('reference-setting-modal', () => { fireEvent.click(screen.getByTestId('auto-update-change')) // Save to verify the change - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { @@ -582,7 +410,7 @@ describe('reference-setting-modal', () => { rerender(<ReferenceSettingModal {...defaultProps} />) // Assert - component should render without issues - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() }) it('handleSave should be memoized with useCallback', async () => { @@ -592,7 +420,7 @@ describe('reference-setting-modal', () => { // Act - rerender and click save rerender(<ReferenceSettingModal {...defaultProps} onSave={onSave} />) - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { @@ -605,7 +433,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Act - click install permission option - const everyoneOptions = screen.getAllByTestId('option-card-everyone') + const everyoneOptions = screen.getAllByTestId('option-card-plugin.privilege.everyone') fireEvent.click(everyoneOptions[0]) // Assert - install permission should be updated @@ -617,24 +445,24 @@ describe('reference-setting-modal', () => { it('should be memoized with React.memo', () => { // Assert expect(ReferenceSettingModal).toBeDefined() - expect((ReferenceSettingModal as any).$$typeof?.toString()).toContain('Symbol') + expect((ReferenceSettingModal as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) describe('Edge Cases and Error Handling', () => { it('should handle null payload gracefully', () => { // Arrange - const payload = null as any + const payload = null as unknown as ReferenceSetting // Act & Assert - should not crash render(<ReferenceSettingModal {...defaultProps} payload={payload} />) - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() }) it('should handle undefined permission values', () => { // Arrange const payload = { - permission: undefined as any, + permission: undefined as unknown as Permissions, auto_upgrade: createMockAutoUpdateConfig(), } @@ -642,7 +470,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Assert - should use default PermissionType.noOne - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') expect(noOneOptions[0]).toHaveAttribute('aria-pressed', 'true') }) @@ -650,7 +478,7 @@ describe('reference-setting-modal', () => { // Arrange const payload = createMockReferenceSetting({ permission: { - install_permission: undefined as any, + install_permission: undefined as unknown as PermissionType, debug_permission: PermissionType.everyone, }, }) @@ -659,7 +487,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Assert - should fall back to PermissionType.noOne - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() }) it('should handle missing debug_permission', () => { @@ -667,7 +495,7 @@ describe('reference-setting-modal', () => { const payload = createMockReferenceSetting({ permission: { install_permission: PermissionType.everyone, - debug_permission: undefined as any, + debug_permission: undefined as unknown as PermissionType, }, }) @@ -675,7 +503,7 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Assert - should fall back to PermissionType.noOne - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() }) it('should handle slow async onSave gracefully', async () => { @@ -690,7 +518,7 @@ describe('reference-setting-modal', () => { // Act render(<ReferenceSettingModal {...defaultProps} onSave={onSave} onHide={onHide} />) - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert - onSave should be called immediately expect(onSave).toHaveBeenCalledTimes(1) @@ -727,7 +555,7 @@ describe('reference-setting-modal', () => { const { unmount } = render(<ReferenceSettingModal {...defaultProps} payload={payload} />) // Assert - should render without crashing - expect(screen.getByText('Plugin Permissions')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.title')).toBeInTheDocument() unmount() }) @@ -802,11 +630,11 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} onSave={onSave} />) // Change install permission to noOne - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') fireEvent.click(noOneOptions[0]) // Save - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert - debug_permission should still be admin await waitFor(() => { @@ -833,11 +661,11 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} payload={payload} onSave={onSave} />) // Change debug permission to noOne - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') fireEvent.click(noOneOptions[1]) // Second one is for debug // Save - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert - install_permission should still be admin await waitFor(() => { @@ -862,11 +690,11 @@ describe('reference-setting-modal', () => { fireEvent.click(screen.getByTestId('auto-update-change')) // Change install permission - const everyoneOptions = screen.getAllByTestId('option-card-everyone') + const everyoneOptions = screen.getAllByTestId('option-card-plugin.privilege.everyone') fireEvent.click(everyoneOptions[0]) // Save - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert - both changes should be saved await waitFor(() => { @@ -907,9 +735,9 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - check order by getting all section labels - const labels = screen.getAllByText(/Who can/) - expect(labels[0]).toHaveTextContent('Who can install plugins') - expect(labels[1]).toHaveTextContent('Who can debug plugins') + const labels = screen.getAllByText(/plugin\.privilege\.whoCan/) + expect(labels[0]).toHaveTextContent('plugin.privilege.whoCanInstall') + expect(labels[1]).toHaveTextContent('plugin.privilege.whoCanDebug') }) it('should render three options per permission section', () => { @@ -917,9 +745,9 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - const everyoneOptions = screen.getAllByTestId('option-card-everyone') - const adminOptions = screen.getAllByTestId('option-card-admins-only') - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const everyoneOptions = screen.getAllByTestId('option-card-plugin.privilege.everyone') + const adminOptions = screen.getAllByTestId('option-card-plugin.privilege.admins') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') expect(everyoneOptions).toHaveLength(2) // One for install, one for debug expect(adminOptions).toHaveLength(2) @@ -931,8 +759,8 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...defaultProps} />) // Assert - const cancelButton = screen.getByText('Cancel') - const saveButton = screen.getByText('Save') + const cancelButton = screen.getByText('common.operation.cancel') + const saveButton = screen.getByText('common.operation.save') expect(cancelButton).toBeInTheDocument() expect(saveButton).toBeInTheDocument() @@ -968,18 +796,18 @@ describe('reference-setting-modal', () => { ) // Change install permission to Everyone - const everyoneOptions = screen.getAllByTestId('option-card-everyone') + const everyoneOptions = screen.getAllByTestId('option-card-plugin.privilege.everyone') fireEvent.click(everyoneOptions[0]) // Change debug permission to Admins Only - const adminOptions = screen.getAllByTestId('option-card-admins-only') + const adminOptions = screen.getAllByTestId('option-card-plugin.privilege.admins') fireEvent.click(adminOptions[1]) // Change auto-update strategy fireEvent.click(screen.getByTestId('auto-update-change')) // Save - fireEvent.click(screen.getByText('Save')) + fireEvent.click(screen.getByText('common.operation.save')) // Assert await waitFor(() => { @@ -1012,11 +840,11 @@ describe('reference-setting-modal', () => { ) // Make some changes - const noOneOptions = screen.getAllByTestId('option-card-no-one') + const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone') fireEvent.click(noOneOptions[0]) // Cancel - fireEvent.click(screen.getByText('Cancel')) + fireEvent.click(screen.getByText('common.operation.cancel')) // Assert expect(onSave).not.toHaveBeenCalled() @@ -1035,8 +863,8 @@ describe('reference-setting-modal', () => { render(<ReferenceSettingModal {...props} />) // Assert - Labels are rendered correctly - expect(screen.getByText('Who can install plugins')).toBeInTheDocument() - expect(screen.getByText('Who can debug plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.whoCanInstall')).toBeInTheDocument() + expect(screen.getByText('plugin.privilege.whoCanDebug')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/reference-setting-modal/__tests__/label.spec.tsx b/web/app/components/plugins/reference-setting-modal/__tests__/label.spec.tsx new file mode 100644 index 0000000000..86fcf15a90 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/__tests__/label.spec.tsx @@ -0,0 +1,97 @@ +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() + }) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx similarity index 85% rename from web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx rename to web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx index 1008ef461d..19ce12b328 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { AutoUpdateConfig } from './types' +import type { AutoUpdateConfig } from '../types' import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' @@ -7,91 +7,28 @@ import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, PluginSource } from '../../types' -import { defaultValue } from './config' -import AutoUpdateSetting from './index' -import NoDataPlaceholder from './no-data-placeholder' -import NoPluginSelected from './no-plugin-selected' -import PluginsPicker from './plugins-picker' -import PluginsSelected from './plugins-selected' -import StrategyPicker from './strategy-picker' -import ToolItem from './tool-item' -import ToolPicker from './tool-picker' -import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from './types' +import { PluginCategoryEnum, PluginSource } from '../../../types' +import { defaultValue } from '../config' +import AutoUpdateSetting from '../index' +import NoDataPlaceholder from '../no-data-placeholder' +import NoPluginSelected from '../no-plugin-selected' +import PluginsPicker from '../plugins-picker' +import PluginsSelected from '../plugins-selected' +import StrategyPicker from '../strategy-picker' +import ToolItem from '../tool-item' +import ToolPicker from '../tool-picker' +import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../types' import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds, dayjsToTimeOfDay, timeOfDayToDayjs, -} from './utils' +} from '../utils' // Setup dayjs plugins dayjs.extend(utc) dayjs.extend(timezone) -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock react-i18next -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal<typeof import('react-i18next')>() - return { - ...actual, - Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => { - if (i18nKey === 'autoUpdate.changeTimezone' && components?.setTimezone) { - return ( - <span> - Change in - {components.setTimezone} - </span> - ) - } - return <span>{i18nKey}</span> - }, - useTranslation: () => ({ - t: (key: string, options?: { ns?: string, num?: number }) => { - const translations: Record<string, string> = { - 'autoUpdate.updateSettings': 'Update Settings', - 'autoUpdate.automaticUpdates': 'Automatic Updates', - 'autoUpdate.updateTime': 'Update Time', - 'autoUpdate.specifyPluginsToUpdate': 'Specify Plugins to Update', - 'autoUpdate.strategy.fixOnly.selectedDescription': 'Only apply bug fixes', - 'autoUpdate.strategy.latest.selectedDescription': 'Always update to latest', - 'autoUpdate.strategy.disabled.name': 'Disabled', - 'autoUpdate.strategy.disabled.description': 'No automatic updates', - 'autoUpdate.strategy.fixOnly.name': 'Bug Fixes Only', - 'autoUpdate.strategy.fixOnly.description': 'Only apply bug fixes and patches', - 'autoUpdate.strategy.latest.name': 'Latest Version', - 'autoUpdate.strategy.latest.description': 'Always update to the latest version', - 'autoUpdate.upgradeMode.all': 'All Plugins', - 'autoUpdate.upgradeMode.exclude': 'Exclude Selected', - 'autoUpdate.upgradeMode.partial': 'Selected Only', - 'autoUpdate.excludeUpdate': `Excluding ${options?.num || 0} plugins`, - 'autoUpdate.partialUPdate': `Updating ${options?.num || 0} plugins`, - 'autoUpdate.operation.clearAll': 'Clear All', - 'autoUpdate.operation.select': 'Select Plugins', - 'autoUpdate.upgradeModePlaceholder.partial': 'Select plugins to update', - 'autoUpdate.upgradeModePlaceholder.exclude': 'Select plugins to exclude', - 'autoUpdate.noPluginPlaceholder.noInstalled': 'No plugins installed', - 'autoUpdate.noPluginPlaceholder.noFound': 'No plugins found', - 'category.all': 'All', - 'category.models': 'Models', - 'category.tools': 'Tools', - 'category.agents': 'Agents', - 'category.extensions': 'Extensions', - 'category.datasources': 'Datasources', - 'category.triggers': 'Triggers', - 'category.bundles': 'Bundles', - 'searchTools': 'Search tools...', - } - const fullKey = options?.ns ? `${options.ns}.${key}` : key - return translations[fullKey] || translations[key] || key - }, - }), - } -}) - // Mock app context const mockTimezone = 'America/New_York' vi.mock('@/context/app-context', () => ({ @@ -262,7 +199,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({ })) // Mock PLUGIN_TYPE_SEARCH_MAP -vi.mock('../../marketplace/constants', () => ({ +vi.mock('../../../marketplace/constants', () => ({ PLUGIN_TYPE_SEARCH_MAP: { all: 'all', model: 'model', @@ -574,7 +511,7 @@ describe('auto-update-setting', () => { // Assert expect(screen.getByTestId('group-icon')).toBeInTheDocument() - expect(screen.getByText('No plugins installed')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noInstalled')).toBeInTheDocument() }) it('should render with noPlugins=false showing search icon', () => { @@ -583,7 +520,7 @@ describe('auto-update-setting', () => { // Assert expect(screen.getByTestId('search-menu-icon')).toBeInTheDocument() - expect(screen.getByText('No plugins found')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noFound')).toBeInTheDocument() }) it('should render with noPlugins=undefined (default) showing search icon', () => { @@ -606,14 +543,11 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(NoDataPlaceholder).toBeDefined() - expect((NoDataPlaceholder as any).$$typeof?.toString()).toContain('Symbol') + expect((NoDataPlaceholder as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) - // ============================================================ - // NoPluginSelected Component Tests - // ============================================================ describe('NoPluginSelected (no-plugin-selected.tsx)', () => { describe('Rendering', () => { it('should render partial mode placeholder', () => { @@ -621,7 +555,7 @@ describe('auto-update-setting', () => { render(<NoPluginSelected updateMode={AUTO_UPDATE_MODE.partial} />) // Assert - expect(screen.getByText('Select plugins to update')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial')).toBeInTheDocument() }) it('should render exclude mode placeholder', () => { @@ -629,21 +563,18 @@ describe('auto-update-setting', () => { render(<NoPluginSelected updateMode={AUTO_UPDATE_MODE.exclude} />) // Assert - expect(screen.getByText('Select plugins to exclude')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.exclude')).toBeInTheDocument() }) }) describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(NoPluginSelected).toBeDefined() - expect((NoPluginSelected as any).$$typeof?.toString()).toContain('Symbol') + expect((NoPluginSelected as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) - // ============================================================ - // PluginsSelected Component Tests - // ============================================================ describe('PluginsSelected (plugins-selected.tsx)', () => { describe('Rendering', () => { it('should render empty when no plugins', () => { @@ -731,14 +662,11 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(PluginsSelected).toBeDefined() - expect((PluginsSelected as any).$$typeof?.toString()).toContain('Symbol') + expect((PluginsSelected as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) - // ============================================================ - // ToolItem Component Tests - // ============================================================ describe('ToolItem (tool-item.tsx)', () => { const defaultProps = { payload: createMockPluginDetail(), @@ -825,14 +753,11 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(ToolItem).toBeDefined() - expect((ToolItem as any).$$typeof?.toString()).toContain('Symbol') + expect((ToolItem as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) - // ============================================================ - // StrategyPicker Component Tests - // ============================================================ describe('StrategyPicker (strategy-picker.tsx)', () => { const defaultProps = { value: AUTO_UPDATE_STRATEGY.disabled, @@ -845,7 +770,7 @@ describe('auto-update-setting', () => { render(<StrategyPicker {...defaultProps} value={AUTO_UPDATE_STRATEGY.disabled} />) // Assert - expect(screen.getByRole('button', { name: /disabled/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.disabled\.name/i })).toBeInTheDocument() }) it('should not render dropdown content when closed', () => { @@ -866,10 +791,10 @@ describe('auto-update-setting', () => { // Wait for portal to open if (mockPortalOpen) { - // Assert all options visible (use getAllByText for "Disabled" as it appears in both trigger and dropdown) - expect(screen.getAllByText('Disabled').length).toBeGreaterThanOrEqual(1) - expect(screen.getByText('Bug Fixes Only')).toBeInTheDocument() - expect(screen.getByText('Latest Version')).toBeInTheDocument() + // Assert all options visible (use getAllByText for strategy name as it appears in both trigger and dropdown) + expect(screen.getAllByText('plugin.autoUpdate.strategy.disabled.name').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.latest.name')).toBeInTheDocument() } }) }) @@ -898,7 +823,7 @@ describe('auto-update-setting', () => { render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />) // Find and click the "Bug Fixes Only" option - const fixOnlyOption = screen.getByText('Bug Fixes Only').closest('div[class*="cursor-pointer"]') + const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]') expect(fixOnlyOption).toBeInTheDocument() fireEvent.click(fixOnlyOption!) @@ -915,7 +840,7 @@ describe('auto-update-setting', () => { render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />) // Find and click the "Latest Version" option - const latestOption = screen.getByText('Latest Version').closest('div[class*="cursor-pointer"]') + const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]') expect(latestOption).toBeInTheDocument() fireEvent.click(latestOption!) @@ -932,7 +857,7 @@ describe('auto-update-setting', () => { render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={onChange} />) // Find and click the "Disabled" option - need to find the one in the dropdown, not the button - const disabledOptions = screen.getAllByText('Disabled') + const disabledOptions = screen.getAllByText('plugin.autoUpdate.strategy.disabled.name') // The second one should be in the dropdown const dropdownOption = disabledOptions.find(el => el.closest('div[class*="cursor-pointer"]')) expect(dropdownOption).toBeInTheDocument() @@ -956,7 +881,7 @@ describe('auto-update-setting', () => { ) // Click an option - const fixOnlyOption = screen.getByText('Bug Fixes Only').closest('div[class*="cursor-pointer"]') + const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]') fireEvent.click(fixOnlyOption!) // Assert - onChange is called but parent click handler should not propagate @@ -972,7 +897,7 @@ describe('auto-update-setting', () => { // Assert - RiCheckLine should be rendered (check icon) // Find all "Bug Fixes Only" texts and get the one in the dropdown (has cursor-pointer parent) - const allFixOnlyTexts = screen.getAllByText('Bug Fixes Only') + const allFixOnlyTexts = screen.getAllByText('plugin.autoUpdate.strategy.fixOnly.name') const dropdownOption = allFixOnlyTexts.find(el => el.closest('div[class*="cursor-pointer"]')) const optionContainer = dropdownOption?.closest('div[class*="cursor-pointer"]') expect(optionContainer).toBeInTheDocument() @@ -988,7 +913,7 @@ describe('auto-update-setting', () => { render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />) // Assert - check the Latest Version option should not have check icon - const latestOption = screen.getByText('Latest Version').closest('div[class*="cursor-pointer"]') + const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]') // The svg should only be in selected option, not in non-selected const checkIconContainer = latestOption?.querySelector('div.mr-1') // Non-selected option should have empty check icon container @@ -997,9 +922,6 @@ describe('auto-update-setting', () => { }) }) - // ============================================================ - // ToolPicker Component Tests - // ============================================================ describe('ToolPicker (tool-picker.tsx)', () => { const defaultProps = { trigger: <button>Select Plugins</button>, @@ -1199,7 +1121,7 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(ToolPicker).toBeDefined() - expect((ToolPicker as any).$$typeof?.toString()).toContain('Symbol') + expect((ToolPicker as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) @@ -1220,7 +1142,7 @@ describe('auto-update-setting', () => { render(<PluginsPicker {...defaultProps} />) // Assert - expect(screen.getByText('Select plugins to update')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial')).toBeInTheDocument() }) it('should render selected plugins count and clear button when plugins selected', () => { @@ -1228,8 +1150,8 @@ describe('auto-update-setting', () => { render(<PluginsPicker {...defaultProps} value={['plugin-1', 'plugin-2']} />) // Assert - expect(screen.getByText(/Updating 2 plugins/i)).toBeInTheDocument() - expect(screen.getByText('Clear All')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"num":2}')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.operation.clearAll')).toBeInTheDocument() }) it('should render select button', () => { @@ -1237,7 +1159,7 @@ describe('auto-update-setting', () => { render(<PluginsPicker {...defaultProps} />) // Assert - expect(screen.getByText('Select Plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.operation.select')).toBeInTheDocument() }) it('should show exclude mode text when in exclude mode', () => { @@ -1251,7 +1173,7 @@ describe('auto-update-setting', () => { ) // Assert - expect(screen.getByText(/Excluding 1 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":1}')).toBeInTheDocument() }) }) @@ -1268,7 +1190,7 @@ describe('auto-update-setting', () => { onChange={onChange} />, ) - fireEvent.click(screen.getByText('Clear All')) + fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) // Assert expect(onChange).toHaveBeenCalledWith([]) @@ -1278,7 +1200,7 @@ describe('auto-update-setting', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(PluginsPicker).toBeDefined() - expect((PluginsPicker as any).$$typeof?.toString()).toContain('Symbol') + expect((PluginsPicker as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) @@ -1298,7 +1220,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} />) // Assert - expect(screen.getByText('Update Settings')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.updateSettings')).toBeInTheDocument() }) it('should render automatic updates label', () => { @@ -1306,7 +1228,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} />) // Assert - expect(screen.getByText('Automatic Updates')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.automaticUpdates')).toBeInTheDocument() }) it('should render strategy picker', () => { @@ -1325,7 +1247,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('Update Time')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.updateTime')).toBeInTheDocument() expect(screen.getByTestId('time-picker')).toBeInTheDocument() }) @@ -1337,7 +1259,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.queryByText('Update Time')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.updateTime')).not.toBeInTheDocument() expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument() }) @@ -1352,7 +1274,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('Select Plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.operation.select')).toBeInTheDocument() }) it('should hide plugins picker when mode is update_all', () => { @@ -1366,7 +1288,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.queryByText('Select Plugins')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.operation.select')).not.toBeInTheDocument() }) }) @@ -1379,7 +1301,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('Only apply bug fixes')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription')).toBeInTheDocument() }) it('should show latest description when strategy is latest', () => { @@ -1390,7 +1312,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('Always update to latest')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.latest.selectedDescription')).toBeInTheDocument() }) it('should show no description when strategy is disabled', () => { @@ -1401,8 +1323,8 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.queryByText('Only apply bug fixes')).not.toBeInTheDocument() - expect(screen.queryByText('Always update to latest')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.strategy.latest.selectedDescription')).not.toBeInTheDocument() }) }) @@ -1420,7 +1342,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText(/Updating 2 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"num":2}')).toBeInTheDocument() }) it('should show exclude_plugins when mode is exclude', () => { @@ -1436,7 +1358,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText(/Excluding 3 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":3}')).toBeInTheDocument() }) }) @@ -1502,7 +1424,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click clear all - fireEvent.click(screen.getByText('Clear All')) + fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) // Assert expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ @@ -1523,7 +1445,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click clear all - fireEvent.click(screen.getByText('Clear All')) + fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) // Assert expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ @@ -1538,8 +1460,8 @@ describe('auto-update-setting', () => { // Act render(<AutoUpdateSetting {...defaultProps} payload={payload} />) - // Assert - timezone text is rendered - expect(screen.getByText(/Change in/i)).toBeInTheDocument() + // Assert - timezone Trans component is rendered + expect(screen.getByText('autoUpdate.changeTimezone')).toBeInTheDocument() }) }) @@ -1571,7 +1493,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Trigger a change (clear plugins) - fireEvent.click(screen.getByText('Clear All')) + fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) // Assert - other values should be preserved expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ @@ -1593,7 +1515,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Plugin picker should not be visible in update_all mode - expect(screen.queryByText('Clear All')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.operation.clearAll')).not.toBeInTheDocument() }) }) @@ -1604,14 +1526,14 @@ describe('auto-update-setting', () => { const { rerender } = render(<AutoUpdateSetting {...defaultProps} payload={payload1} />) // Assert initial - expect(screen.getByText('Only apply bug fixes')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.selectedDescription')).toBeInTheDocument() // Act - change strategy const payload2 = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.latest }) rerender(<AutoUpdateSetting {...defaultProps} payload={payload2} />) // Assert updated - expect(screen.getByText('Always update to latest')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.latest.selectedDescription')).toBeInTheDocument() }) it('plugins should reflect correct list based on upgrade_mode', () => { @@ -1625,7 +1547,7 @@ describe('auto-update-setting', () => { const { rerender } = render(<AutoUpdateSetting {...defaultProps} payload={partialPayload} />) // Assert - partial mode shows include_plugins count - expect(screen.getByText(/Updating 2 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"num":2}')).toBeInTheDocument() // Act - change to exclude mode const excludePayload = createMockAutoUpdateConfig({ @@ -1637,14 +1559,14 @@ describe('auto-update-setting', () => { rerender(<AutoUpdateSetting {...defaultProps} payload={excludePayload} />) // Assert - exclude mode shows exclude_plugins count - expect(screen.getByText(/Excluding 1 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":1}')).toBeInTheDocument() }) }) describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(AutoUpdateSetting).toBeDefined() - expect((AutoUpdateSetting as any).$$typeof?.toString()).toContain('Symbol') + expect((AutoUpdateSetting as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) @@ -1661,7 +1583,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('Update Settings')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.updateSettings')).toBeInTheDocument() }) it('should handle null timezone gracefully', () => { @@ -1697,9 +1619,9 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - expect(screen.getByText('All Plugins')).toBeInTheDocument() - expect(screen.getByText('Exclude Selected')).toBeInTheDocument() - expect(screen.getByText('Selected Only')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.all')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.exclude')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.partial')).toBeInTheDocument() }) it('should highlight selected upgrade mode', () => { @@ -1713,9 +1635,9 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting {...defaultProps} payload={payload} />) // Assert - OptionCard component will be rendered for each mode - expect(screen.getByText('All Plugins')).toBeInTheDocument() - expect(screen.getByText('Exclude Selected')).toBeInTheDocument() - expect(screen.getByText('Selected Only')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.all')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.exclude')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.upgradeMode.partial')).toBeInTheDocument() }) it('should call onChange when upgrade mode is changed', () => { @@ -1730,7 +1652,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Click on partial mode - find the option card for partial - const partialOption = screen.getByText('Selected Only') + const partialOption = screen.getByText('plugin.autoUpdate.upgradeMode.partial') fireEvent.click(partialOption) // Assert @@ -1769,7 +1691,7 @@ describe('auto-update-setting', () => { // Assert - time picker and plugins visible expect(screen.getByTestId('time-picker')).toBeInTheDocument() - expect(screen.getByText('Select Plugins')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.operation.select')).toBeInTheDocument() }) it('should maintain state consistency when switching modes', () => { @@ -1786,7 +1708,7 @@ describe('auto-update-setting', () => { render(<AutoUpdateSetting payload={payload} onChange={onChange} />) // Assert - partial mode shows include_plugins - expect(screen.getByText(/Updating 1 plugins/i)).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.partialUPdate:{"num":1}')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/utils.spec.ts b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/utils.spec.ts similarity index 94% rename from web/app/components/plugins/reference-setting-modal/auto-update-setting/utils.spec.ts rename to web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/utils.spec.ts index f813338c98..c23072021e 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/utils.spec.ts +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/utils.spec.ts @@ -1,4 +1,4 @@ -import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds } from './utils' +import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds } from '../utils' describe('convertLocalSecondsToUTCDaySeconds', () => { it('should convert local seconds to UTC day seconds correctly', () => { diff --git a/web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx new file mode 100644 index 0000000000..be446f98d1 --- /dev/null +++ b/web/app/components/plugins/update-plugin/__tests__/downgrade-warning.spec.tsx @@ -0,0 +1,78 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DowngradeWarningModal from '../downgrade-warning' + +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('plugin.autoUpdate.pluginDowngradeWarning.title')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description')).toBeInTheDocument() + }) + + it('renders three action buttons', () => { + render( + <DowngradeWarningModal + onCancel={mockOnCancel} + onJustDowngrade={mockOnJustDowngrade} + onExcludeAndDowngrade={mockOnExcludeAndDowngrade} + />, + ) + expect(screen.getByText('app.newApp.Cancel')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.downgrade')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.exclude')).toBeInTheDocument() + }) + + it('calls onCancel when Cancel is clicked', () => { + render( + <DowngradeWarningModal + onCancel={mockOnCancel} + onJustDowngrade={mockOnJustDowngrade} + onExcludeAndDowngrade={mockOnExcludeAndDowngrade} + />, + ) + fireEvent.click(screen.getByText('app.newApp.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('plugin.autoUpdate.pluginDowngradeWarning.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('plugin.autoUpdate.pluginDowngradeWarning.exclude')) + expect(mockOnExcludeAndDowngrade).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx new file mode 100644 index 0000000000..1ce1a1a0af --- /dev/null +++ b/web/app/components/plugins/update-plugin/__tests__/from-github.spec.tsx @@ -0,0 +1,51 @@ +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() + }) +}) diff --git a/web/app/components/plugins/update-plugin/index.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx similarity index 87% rename from web/app/components/plugins/update-plugin/index.spec.tsx rename to web/app/components/plugins/update-plugin/__tests__/index.spec.tsx index 2d4635f83b..8a4b2187b5 100644 --- a/web/app/components/plugins/update-plugin/index.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx @@ -3,50 +3,17 @@ import type { UpdateFromGitHubPayload, UpdateFromMarketPlacePayload, UpdatePluginModalType, -} from '../types' +} from '../../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PluginCategoryEnum, PluginSource, TaskStatus } from '../types' -import DowngradeWarningModal from './downgrade-warning' -import FromGitHub from './from-github' -import UpdateFromMarketplace from './from-market-place' -import UpdatePlugin from './index' -import PluginVersionPicker from './plugin-version-picker' - -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock react-i18next -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal<typeof import('react-i18next')>() - return { - ...actual, - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const translations: Record<string, string> = { - 'upgrade.title': 'Update Plugin', - 'upgrade.successfulTitle': 'Plugin Updated', - 'upgrade.description': 'This plugin will be updated to the new version.', - 'upgrade.upgrade': 'Update', - 'upgrade.upgrading': 'Updating...', - 'upgrade.close': 'Close', - 'operation.cancel': 'Cancel', - 'newApp.Cancel': 'Cancel', - 'autoUpdate.pluginDowngradeWarning.title': 'Downgrade Warning', - 'autoUpdate.pluginDowngradeWarning.description': 'You are about to downgrade this plugin.', - 'autoUpdate.pluginDowngradeWarning.downgrade': 'Just Downgrade', - 'autoUpdate.pluginDowngradeWarning.exclude': 'Exclude and Downgrade', - 'detailPanel.switchVersion': 'Switch Version', - } - const fullKey = options?.ns ? `${options.ns}.${key}` : key - return translations[fullKey] || translations[key] || key - }, - }), - } -}) +import { PluginCategoryEnum, PluginSource, TaskStatus } from '../../types' +import DowngradeWarningModal from '../downgrade-warning' +import FromGitHub from '../from-github' +import UpdateFromMarketplace from '../from-market-place' +import UpdatePlugin from '../index' +import PluginVersionPicker from '../plugin-version-picker' // Mock useGetLanguage context vi.mock('@/context/i18n', () => ({ @@ -108,7 +75,7 @@ vi.mock('@/service/use-plugins', () => ({ // Mock checkTaskStatus const mockCheck = vi.fn() const mockStop = vi.fn() -vi.mock('../install-plugin/base/check-task-status', () => ({ +vi.mock('../../install-plugin/base/check-task-status', () => ({ default: () => ({ check: mockCheck, stop: mockStop, @@ -116,14 +83,14 @@ vi.mock('../install-plugin/base/check-task-status', () => ({ })) // Mock Toast -vi.mock('../../base/toast', () => ({ +vi.mock('../../../base/toast', () => ({ default: { notify: vi.fn(), }, })) // Mock InstallFromGitHub component -vi.mock('../install-plugin/install-from-github', () => ({ +vi.mock('../../install-plugin/install-from-github', () => ({ default: ({ updatePayload, onClose, onSuccess }: { updatePayload: UpdateFromGitHubPayload onClose: () => void @@ -320,7 +287,7 @@ describe('update-plugin', () => { renderWithQueryClient(<UpdatePlugin {...props} />) // Assert - expect(screen.getByText('Update Plugin')).toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() }) it('should render UpdateFromMarketplace for other plugin sources', () => { @@ -337,7 +304,7 @@ describe('update-plugin', () => { renderWithQueryClient(<UpdatePlugin {...props} />) // Assert - expect(screen.getByText('Update Plugin')).toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() }) }) @@ -346,7 +313,7 @@ describe('update-plugin', () => { // Verify the component is wrapped with React.memo expect(UpdatePlugin).toBeDefined() // The component should have $$typeof indicating it's a memo component - expect((UpdatePlugin as any).$$typeof?.toString()).toContain('Symbol') + expect((UpdatePlugin as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) @@ -440,7 +407,7 @@ describe('update-plugin', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(FromGitHub).toBeDefined() - expect((FromGitHub as any).$$typeof?.toString()).toContain('Symbol') + expect((FromGitHub as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) @@ -502,8 +469,8 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByText('Update Plugin')).toBeInTheDocument() - expect(screen.getByText('This plugin will be updated to the new version.')).toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.description')).toBeInTheDocument() }) it('should render version badge with version transition', () => { @@ -546,8 +513,8 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() }) }) @@ -567,8 +534,8 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByText('Downgrade Warning')).toBeInTheDocument() - expect(screen.getByText('You are about to downgrade this plugin.')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.title')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description')).toBeInTheDocument() }) it('should not show downgrade warning modal when isShowDowngradeWarningModal is false', () => { @@ -586,8 +553,8 @@ describe('update-plugin', () => { ) // Assert - expect(screen.queryByText('Downgrade Warning')).not.toBeInTheDocument() - expect(screen.getByText('Update Plugin')).toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.pluginDowngradeWarning.title')).not.toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() }) }) @@ -605,7 +572,7 @@ describe('update-plugin', () => { onCancel={onCancel} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) // Assert expect(onCancel).toHaveBeenCalledTimes(1) @@ -628,7 +595,7 @@ describe('update-plugin', () => { onCancel={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert await waitFor(() => { @@ -654,14 +621,14 @@ describe('update-plugin', () => { ) // Assert - button should show Update before clicking - expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument() // Act - click update button - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert - Cancel button should be hidden during upgrade await waitFor(() => { - expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument() }) }) @@ -682,7 +649,7 @@ describe('update-plugin', () => { onCancel={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert await waitFor(() => { @@ -708,7 +675,7 @@ describe('update-plugin', () => { onCancel={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert await waitFor(() => { @@ -735,7 +702,7 @@ describe('update-plugin', () => { onCancel={onCancel} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) // Assert expect(mockStop).toHaveBeenCalled() @@ -757,18 +724,18 @@ describe('update-plugin', () => { onCancel={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert await waitFor(() => { - expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument() }) }) it('should show error toast when task status is failed', async () => { // Arrange - covers lines 99-100 const mockToastNotify = vi.fn() - vi.mocked(await import('../../base/toast')).default.notify = mockToastNotify + vi.mocked(await import('../../../base/toast')).default.notify = mockToastNotify mockUpdateFromMarketPlace.mockResolvedValue({ all_installed: false, @@ -789,7 +756,7 @@ describe('update-plugin', () => { onCancel={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Update' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) // Assert await waitFor(() => { @@ -809,7 +776,7 @@ describe('update-plugin', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(UpdateFromMarketplace).toBeDefined() - expect((UpdateFromMarketplace as any).$$typeof?.toString()).toContain('Symbol') + expect((UpdateFromMarketplace as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) @@ -833,7 +800,7 @@ describe('update-plugin', () => { isShowDowngradeWarningModal={true} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Exclude and Downgrade' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })) // Assert await waitFor(() => { @@ -865,7 +832,7 @@ describe('update-plugin', () => { isShowDowngradeWarningModal={true} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Exclude and Downgrade' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })) // Assert - mutateAsync should NOT be called when pluginId is undefined await waitFor(() => { @@ -892,8 +859,8 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByText('Downgrade Warning')).toBeInTheDocument() - expect(screen.getByText('You are about to downgrade this plugin.')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.title')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.pluginDowngradeWarning.description')).toBeInTheDocument() }) it('should render all three action buttons', () => { @@ -907,9 +874,9 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Just Downgrade' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Exclude and Downgrade' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'app.newApp.Cancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.downgrade' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })).toBeInTheDocument() }) }) @@ -926,7 +893,7 @@ describe('update-plugin', () => { onExcludeAndDowngrade={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Cancel' })) // Assert expect(onCancel).toHaveBeenCalledTimes(1) @@ -944,7 +911,7 @@ describe('update-plugin', () => { onExcludeAndDowngrade={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Just Downgrade' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.downgrade' })) // Assert expect(onJustDowngrade).toHaveBeenCalledTimes(1) @@ -962,7 +929,7 @@ describe('update-plugin', () => { onExcludeAndDowngrade={onExcludeAndDowngrade} />, ) - fireEvent.click(screen.getByRole('button', { name: 'Exclude and Downgrade' })) + fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.pluginDowngradeWarning.exclude' })) // Assert expect(onExcludeAndDowngrade).toHaveBeenCalledTimes(1) @@ -1006,7 +973,7 @@ describe('update-plugin', () => { // Assert expect(screen.getByTestId('portal-content')).toBeInTheDocument() - expect(screen.getByText('Switch Version')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument() }) it('should render all versions from API', () => { @@ -1170,7 +1137,7 @@ describe('update-plugin', () => { describe('Component Memoization', () => { it('should be memoized with React.memo', () => { expect(PluginVersionPicker).toBeDefined() - expect((PluginVersionPicker as any).$$typeof?.toString()).toContain('Symbol') + expect((PluginVersionPicker as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) }) @@ -1212,7 +1179,7 @@ describe('update-plugin', () => { it('should handle empty version list in PluginVersionPicker', () => { // Override the mock temporarily - vi.mocked(vi.importActual('@/service/use-plugins') as any).useVersionListOfPlugin = () => ({ + vi.mocked(vi.importActual('@/service/use-plugins') as unknown as Record<string, unknown>).useVersionListOfPlugin = () => ({ data: { data: { versions: [] } }, }) @@ -1230,7 +1197,7 @@ describe('update-plugin', () => { ) // Assert - expect(screen.getByText('Switch Version')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/tools/__tests__/provider-list.spec.tsx b/web/app/components/tools/__tests__/provider-list.spec.tsx new file mode 100644 index 0000000000..ad703bf43a --- /dev/null +++ b/web/app/components/tools/__tests__/provider-list.spec.tsx @@ -0,0 +1,263 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ProviderList from '../provider-list' + +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('tools.type.builtIn') + expect(screen.getByTestId('tab-api')).toHaveTextContent('tools.type.custom') + expect(screen.getByTestId('tab-workflow')).toHaveTextContent('tools.type.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() + }) + }) +}) diff --git a/web/app/components/tools/edit-custom-collection-modal/config-credentials.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx similarity index 99% rename from web/app/components/tools/edit-custom-collection-modal/config-credentials.spec.tsx rename to web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx index 31cda9b459..ec4866b212 100644 --- a/web/app/components/tools/edit-custom-collection-modal/config-credentials.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/config-credentials.spec.tsx @@ -2,7 +2,7 @@ import type { Credential } from '@/app/components/tools/types' import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types' -import ConfigCredential from './config-credentials' +import ConfigCredential from '../config-credentials' describe('ConfigCredential', () => { const baseCredential: Credential = { diff --git a/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx similarity index 94% rename from web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx rename to web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx index fa316c4aab..edd2d3dc43 100644 --- a/web/app/components/tools/edit-custom-collection-modal/get-schema.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx @@ -1,8 +1,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { importSchemaFromURL } from '@/service/tools' -import Toast from '../../base/toast' -import examples from './examples' -import GetSchema from './get-schema' +import Toast from '../../../base/toast' +import examples from '../examples' +import GetSchema from '../get-schema' vi.mock('@/service/tools', () => ({ importSchemaFromURL: vi.fn(), diff --git a/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/tools/edit-custom-collection-modal/index.spec.tsx rename to web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx index 97fc03175d..3b821080e4 100644 --- a/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx @@ -6,7 +6,7 @@ import Toast from '@/app/components/base/toast' import { Plan } from '@/app/components/billing/type' import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types' import { parseParamsSchema } from '@/service/tools' -import EditCustomCollectionModal from './index' +import EditCustomCollectionModal from '../index' vi.mock('ahooks', async () => { const actual = await vi.importActual<typeof import('ahooks')>('ahooks') diff --git a/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx similarity index 99% rename from web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx rename to web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx index 5cf07c9b19..df35ace68d 100644 --- a/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx @@ -3,7 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types' import { testAPIAvailable } from '@/service/tools' -import TestApi from './test-api' +import TestApi from '../test-api' vi.mock('@/service/tools', () => ({ testAPIAvailable: vi.fn(), diff --git a/web/app/components/tools/labels/filter.spec.tsx b/web/app/components/tools/labels/__tests__/filter.spec.tsx similarity index 99% rename from web/app/components/tools/labels/filter.spec.tsx rename to web/app/components/tools/labels/__tests__/filter.spec.tsx index eeacff30a9..7b88cb1bbd 100644 --- a/web/app/components/tools/labels/filter.spec.tsx +++ b/web/app/components/tools/labels/__tests__/filter.spec.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import LabelFilter from './filter' +import LabelFilter from '../filter' // Mock useTags hook with controlled test data const mockTags = [ diff --git a/web/app/components/tools/labels/selector.spec.tsx b/web/app/components/tools/labels/__tests__/selector.spec.tsx similarity index 99% rename from web/app/components/tools/labels/selector.spec.tsx rename to web/app/components/tools/labels/__tests__/selector.spec.tsx index ebe273abf9..b495d2d227 100644 --- a/web/app/components/tools/labels/selector.spec.tsx +++ b/web/app/components/tools/labels/__tests__/selector.spec.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import LabelSelector from './selector' +import LabelSelector from '../selector' // Mock useTags hook with controlled test data const mockTags = [ diff --git a/web/app/components/tools/labels/__tests__/store.spec.ts b/web/app/components/tools/labels/__tests__/store.spec.ts new file mode 100644 index 0000000000..c5d6a174cc --- /dev/null +++ b/web/app/components/tools/labels/__tests__/store.spec.ts @@ -0,0 +1,41 @@ +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() + }) +}) diff --git a/web/app/components/tools/marketplace/__tests__/hooks.spec.ts b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..14244f763c --- /dev/null +++ b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts @@ -0,0 +1,201 @@ +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'), +})) + +// ==================== 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() + }) + }) +}) diff --git a/web/app/components/tools/marketplace/__tests__/index.spec.tsx b/web/app/components/tools/marketplace/__tests__/index.spec.tsx new file mode 100644 index 0000000000..43c303b075 --- /dev/null +++ b/web/app/components/tools/marketplace/__tests__/index.spec.tsx @@ -0,0 +1,180 @@ +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 userEvent from '@testing-library/user-event' +import * as React from 'react' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { CollectionType } from '@/app/components/tools/types' +import { getMarketplaceUrl } from '@/utils/var' + +import Marketplace from '../index' + +const listRenderSpy = vi.fn() +vi.mock('@/app/components/plugins/marketplace/list', () => ({ + default: (props: { + marketplaceCollections: unknown[] + marketplaceCollectionPluginsMap: Record<string, unknown[]> + plugins?: unknown[] + showInstallButton?: boolean + }) => { + listRenderSpy(props) + return <div data-testid="marketplace-list" /> + }, +})) + +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'), +})) + +const mockGetMarketplaceUrl = vi.mocked(getMarketplaceUrl) + +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 createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({ + type: 'plugin', + org: 'org', + author: 'author', + name: 'Plugin One', + plugin_id: 'plugin-1', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'plugin-1@1.0.0', + icon: 'icon', + verified: true, + label: { en_US: 'Plugin One' }, + brief: { en_US: 'Brief' }, + description: { en_US: 'Plugin description' }, + introduction: 'Intro', + repository: 'https://example.com', + category: PluginCategoryEnum.tool, + install_count: 0, + endpoint: { settings: [] }, + tags: [{ name: 'tag' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({ + isLoading: false, + marketplaceCollections: [], + marketplaceCollectionPluginsMap: {}, + plugins: [], + handleScroll: vi.fn(), + page: 1, + ...overrides, +}) + +describe('Marketplace', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering the marketplace panel based on loading and visibility state. + describe('Rendering', () => { + it('should show loading indicator when loading first page', () => { + // Arrange + const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 }) + render( + <Marketplace + searchPluginText="" + filterPluginTags={[]} + isMarketplaceArrowVisible={false} + showMarketplacePanel={vi.fn()} + marketplaceContext={marketplaceContext} + />, + ) + + // Assert + expect(document.querySelector('svg.spin-animation')).toBeInTheDocument() + expect(screen.queryByTestId('marketplace-list')).not.toBeInTheDocument() + }) + + it('should render list when not loading', () => { + // Arrange + const marketplaceContext = createMarketplaceContext({ + isLoading: false, + plugins: [createPlugin()], + }) + render( + <Marketplace + searchPluginText="" + filterPluginTags={[]} + isMarketplaceArrowVisible={false} + showMarketplacePanel={vi.fn()} + marketplaceContext={marketplaceContext} + />, + ) + + // Assert + expect(screen.getByTestId('marketplace-list')).toBeInTheDocument() + expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({ + showInstallButton: true, + })) + }) + }) + + // Prop-driven UI output such as links and action triggers. + describe('Props', () => { + it('should build marketplace link and trigger panel when arrow is clicked', async () => { + const user = userEvent.setup() + // Arrange + const marketplaceContext = createMarketplaceContext() + const showMarketplacePanel = vi.fn() + const { container } = render( + <Marketplace + searchPluginText="vector" + filterPluginTags={['tag-a', 'tag-b']} + isMarketplaceArrowVisible + showMarketplacePanel={showMarketplacePanel} + marketplaceContext={marketplaceContext} + />, + ) + + // Act + const arrowIcon = container.querySelector('svg.cursor-pointer') + expect(arrowIcon).toBeTruthy() + await user.click(arrowIcon as SVGElement) + + // Assert + expect(showMarketplacePanel).toHaveBeenCalledTimes(1) + expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('', { + language: 'en', + q: 'vector', + tags: 'tag-a,tag-b', + theme: undefined, + }) + const marketplaceLink = screen.getByRole('link', { name: /plugin.marketplace.difyMarketplace/i }) + expect(marketplaceLink).toHaveAttribute('href', 'https://marketplace.test/market') + }) + }) +}) + +// useMarketplace hook tests moved to hooks.spec.ts diff --git a/web/app/components/tools/marketplace/index.spec.tsx b/web/app/components/tools/marketplace/index.spec.tsx deleted file mode 100644 index 493d960e2a..0000000000 --- a/web/app/components/tools/marketplace/index.spec.tsx +++ /dev/null @@ -1,360 +0,0 @@ -import type { Plugin } from '@/app/components/plugins/types' -import type { Collection } from '@/app/components/tools/types' -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' - -const listRenderSpy = vi.fn() -vi.mock('@/app/components/plugins/marketplace/list', () => ({ - default: (props: { - marketplaceCollections: unknown[] - marketplaceCollectionPluginsMap: Record<string, unknown[]> - plugins?: unknown[] - showInstallButton?: boolean - }) => { - listRenderSpy(props) - return <div data-testid="marketplace-list" /> - }, -})) - -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' }), -})) - -const mockGetMarketplaceUrl = vi.mocked(getMarketplaceUrl) - -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 createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({ - type: 'plugin', - org: 'org', - author: 'author', - name: 'Plugin One', - plugin_id: 'plugin-1', - version: '1.0.0', - latest_version: '1.0.0', - latest_package_identifier: 'plugin-1@1.0.0', - icon: 'icon', - verified: true, - label: { en_US: 'Plugin One' }, - brief: { en_US: 'Brief' }, - description: { en_US: 'Plugin description' }, - introduction: 'Intro', - repository: 'https://example.com', - category: PluginCategoryEnum.tool, - install_count: 0, - endpoint: { settings: [] }, - tags: [{ name: 'tag' }], - badges: [], - verification: { authorized_category: 'community' }, - from: 'marketplace', - ...overrides, -}) - -const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({ - isLoading: false, - marketplaceCollections: [], - marketplaceCollectionPluginsMap: {}, - plugins: [], - handleScroll: vi.fn(), - page: 1, - ...overrides, -}) - -describe('Marketplace', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Rendering the marketplace panel based on loading and visibility state. - describe('Rendering', () => { - it('should show loading indicator when loading first page', () => { - // Arrange - const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 }) - render( - <Marketplace - searchPluginText="" - filterPluginTags={[]} - isMarketplaceArrowVisible={false} - showMarketplacePanel={vi.fn()} - marketplaceContext={marketplaceContext} - />, - ) - - // Assert - expect(document.querySelector('svg.spin-animation')).toBeInTheDocument() - expect(screen.queryByTestId('marketplace-list')).not.toBeInTheDocument() - }) - - it('should render list when not loading', () => { - // Arrange - const marketplaceContext = createMarketplaceContext({ - isLoading: false, - plugins: [createPlugin()], - }) - render( - <Marketplace - searchPluginText="" - filterPluginTags={[]} - isMarketplaceArrowVisible={false} - showMarketplacePanel={vi.fn()} - marketplaceContext={marketplaceContext} - />, - ) - - // Assert - expect(screen.getByTestId('marketplace-list')).toBeInTheDocument() - expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({ - showInstallButton: true, - })) - }) - }) - - // Prop-driven UI output such as links and action triggers. - describe('Props', () => { - it('should build marketplace link and trigger panel when arrow is clicked', async () => { - const user = userEvent.setup() - // Arrange - const marketplaceContext = createMarketplaceContext() - const showMarketplacePanel = vi.fn() - const { container } = render( - <Marketplace - searchPluginText="vector" - filterPluginTags={['tag-a', 'tag-b']} - isMarketplaceArrowVisible - showMarketplacePanel={showMarketplacePanel} - marketplaceContext={marketplaceContext} - />, - ) - - // Act - const arrowIcon = container.querySelector('svg.cursor-pointer') - expect(arrowIcon).toBeTruthy() - await user.click(arrowIcon as SVGElement) - - // Assert - expect(showMarketplacePanel).toHaveBeenCalledTimes(1) - expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('', { - language: 'en', - q: 'vector', - tags: 'tag-a,tag-b', - theme: 'light', - }) - const marketplaceLink = screen.getByRole('link', { name: /plugin.marketplace.difyMarketplace/i }) - expect(marketplaceLink).toHaveAttribute('href', 'https://marketplace.test/market') - }) - }) -}) - -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() - }) - }) -}) diff --git a/web/app/components/tools/mcp/create-card.spec.tsx b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/create-card.spec.tsx rename to web/app/components/tools/mcp/__tests__/create-card.spec.tsx index 9ddee00460..6e5b4038f4 100644 --- a/web/app/components/tools/mcp/create-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx @@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import NewMCPCard from './create-card' +import NewMCPCard from '../create-card' // Track the mock functions const mockCreateMCP = vi.fn().mockResolvedValue({ id: 'new-mcp-id', name: 'New MCP' }) @@ -22,7 +22,7 @@ type MockMCPModalProps = { onHide: () => void } -vi.mock('./modal', () => ({ +vi.mock('../modal', () => ({ default: ({ show, onConfirm, onHide }: MockMCPModalProps) => { if (!show) return null diff --git a/web/app/components/tools/mcp/headers-input.spec.tsx b/web/app/components/tools/mcp/__tests__/headers-input.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/headers-input.spec.tsx rename to web/app/components/tools/mcp/__tests__/headers-input.spec.tsx index c271268f5f..881beb00f1 100644 --- a/web/app/components/tools/mcp/headers-input.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/headers-input.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import HeadersInput from './headers-input' +import HeadersInput from '../headers-input' describe('HeadersInput', () => { const defaultProps = { diff --git a/web/app/components/tools/mcp/index.spec.tsx b/web/app/components/tools/mcp/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/index.spec.tsx rename to web/app/components/tools/mcp/__tests__/index.spec.tsx index d48f7efe14..58510dab4c 100644 --- a/web/app/components/tools/mcp/index.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import MCPList from './index' +import MCPList from '../index' type MockProvider = { id: string @@ -22,7 +22,7 @@ vi.mock('@/service/use-tools', () => ({ })) // Mock child components -vi.mock('./create-card', () => ({ +vi.mock('../create-card', () => ({ default: ({ handleCreate }: { handleCreate: (provider: { id: string, name: string }) => void }) => ( <div data-testid="create-card" onClick={() => handleCreate({ id: 'new-id', name: 'New Provider' })}> Create Card @@ -30,7 +30,7 @@ vi.mock('./create-card', () => ({ ), })) -vi.mock('./provider-card', () => ({ +vi.mock('../provider-card', () => ({ default: ({ data, handleSelect, onUpdate, onDeleted }: { data: MockProvider, handleSelect: (id: string) => void, onUpdate: (id: string) => void, onDeleted: () => void }) => { const displayName = typeof data.name === 'string' ? data.name : Object.values(data.name)[0] return ( @@ -43,7 +43,7 @@ vi.mock('./provider-card', () => ({ }, })) -vi.mock('./detail/provider-detail', () => ({ +vi.mock('../detail/provider-detail', () => ({ default: ({ detail, onHide, onUpdate, isTriggerAuthorize, onFirstCreate }: { detail: MockDetail, onHide: () => void, onUpdate: () => void, isTriggerAuthorize: boolean, onFirstCreate: () => void }) => { const displayName = detail?.name ? (typeof detail.name === 'string' ? detail.name : Object.values(detail.name)[0]) diff --git a/web/app/components/tools/mcp/mcp-server-modal.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/mcp-server-modal.spec.tsx rename to web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx index 62eabd0690..6f5c548ec3 100644 --- a/web/app/components/tools/mcp/mcp-server-modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { describe, expect, it, vi } from 'vitest' -import MCPServerModal from './mcp-server-modal' +import MCPServerModal from '../mcp-server-modal' // Mock the services vi.mock('@/service/use-tools', () => ({ diff --git a/web/app/components/tools/mcp/mcp-server-param-item.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-server-param-item.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/mcp-server-param-item.spec.tsx rename to web/app/components/tools/mcp/__tests__/mcp-server-param-item.spec.tsx index 6e3a48e330..d7de650df8 100644 --- a/web/app/components/tools/mcp/mcp-server-param-item.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-server-param-item.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import MCPServerParamItem from './mcp-server-param-item' +import MCPServerParamItem from '../mcp-server-param-item' describe('MCPServerParamItem', () => { const defaultProps = { diff --git a/web/app/components/tools/mcp/mcp-service-card.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/mcp-service-card.spec.tsx rename to web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx index 25e5d6d570..bc170ad2cd 100644 --- a/web/app/components/tools/mcp/mcp-service-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx @@ -7,7 +7,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' -import MCPServiceCard from './mcp-service-card' +import MCPServiceCard from '../mcp-service-card' // Mock MCPServerModal vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({ @@ -96,7 +96,7 @@ const createDefaultHookState = (): MockHookState => ({ let mockHookState = createDefaultHookState() // Mock the hook - uses mockHookState which can be modified per test -vi.mock('./hooks/use-mcp-service-card', () => ({ +vi.mock('../hooks/use-mcp-service-card', () => ({ useMCPServiceCardState: () => ({ ...mockHookState, handleStatusChange: mockHandleStatusChange, diff --git a/web/app/components/tools/mcp/modal.spec.tsx b/web/app/components/tools/mcp/__tests__/modal.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/modal.spec.tsx rename to web/app/components/tools/mcp/__tests__/modal.spec.tsx index c2fe8b46c3..af24ba6061 100644 --- a/web/app/components/tools/mcp/modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/modal.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { describe, expect, it, vi } from 'vitest' -import MCPModal from './modal' +import MCPModal from '../modal' // Mock the service API vi.mock('@/service/common', () => ({ diff --git a/web/app/components/tools/mcp/provider-card.spec.tsx b/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/provider-card.spec.tsx rename to web/app/components/tools/mcp/__tests__/provider-card.spec.tsx index 216607ce5a..d8f644112e 100644 --- a/web/app/components/tools/mcp/provider-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import MCPCard from './provider-card' +import MCPCard from '../provider-card' // Mutable mock functions const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' }) @@ -32,7 +32,7 @@ type MCPModalProps = { onHide: () => void } -vi.mock('./modal', () => ({ +vi.mock('../modal', () => ({ default: ({ show, onConfirm, onHide }: MCPModalProps) => { if (!show) return null @@ -81,7 +81,7 @@ type OperationDropdownProps = { onOpenChange: (open: boolean) => void } -vi.mock('./detail/operation-dropdown', () => ({ +vi.mock('../detail/operation-dropdown', () => ({ default: ({ onEdit, onRemove, onOpenChange }: OperationDropdownProps) => ( <div data-testid="operation-dropdown"> <button diff --git a/web/app/components/tools/mcp/detail/content.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/detail/content.spec.tsx rename to web/app/components/tools/mcp/detail/__tests__/content.spec.tsx index fe3fbd2bc3..20a590459b 100644 --- a/web/app/components/tools/mcp/detail/content.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import MCPDetailContent from './content' +import MCPDetailContent from '../content' // Mutable mock functions const mockUpdateTools = vi.fn().mockResolvedValue({}) @@ -67,7 +67,7 @@ type MCPModalProps = { onHide: () => void } -vi.mock('../modal', () => ({ +vi.mock('../../modal', () => ({ default: ({ show, onConfirm, onHide }: MCPModalProps) => { if (!show) return null @@ -99,7 +99,7 @@ vi.mock('@/app/components/base/confirm', () => ({ })) // Mock OperationDropdown -vi.mock('./operation-dropdown', () => ({ +vi.mock('../operation-dropdown', () => ({ default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => ( <div data-testid="operation-dropdown"> <button data-testid="edit-btn" onClick={onEdit}>Edit</button> @@ -113,7 +113,7 @@ type ToolItemData = { name: string } -vi.mock('./tool-item', () => ({ +vi.mock('../tool-item', () => ({ default: ({ tool }: { tool: ToolItemData }) => ( <div data-testid="tool-item">{tool.name}</div> ), diff --git a/web/app/components/tools/mcp/detail/list-loading.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/list-loading.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/detail/list-loading.spec.tsx rename to web/app/components/tools/mcp/detail/__tests__/list-loading.spec.tsx index 679d4322d9..79fb8282b0 100644 --- a/web/app/components/tools/mcp/detail/list-loading.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/list-loading.spec.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import ListLoading from './list-loading' +import ListLoading from '../list-loading' describe('ListLoading', () => { describe('Rendering', () => { diff --git a/web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx rename to web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx index 077bdc3efe..0b4773f796 100644 --- a/web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/operation-dropdown.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import OperationDropdown from './operation-dropdown' +import OperationDropdown from '../operation-dropdown' describe('OperationDropdown', () => { const defaultProps = { diff --git a/web/app/components/tools/mcp/detail/provider-detail.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/detail/provider-detail.spec.tsx rename to web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx index dc8a427498..05380916b2 100644 --- a/web/app/components/tools/mcp/detail/provider-detail.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { describe, expect, it, vi } from 'vitest' -import MCPDetailPanel from './provider-detail' +import MCPDetailPanel from '../provider-detail' // Mock the drawer component vi.mock('@/app/components/base/drawer', () => ({ @@ -16,7 +16,7 @@ vi.mock('@/app/components/base/drawer', () => ({ })) // Mock the content component to expose onUpdate callback -vi.mock('./content', () => ({ +vi.mock('../content', () => ({ default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => ( <div data-testid="mcp-detail-content"> {detail.name} diff --git a/web/app/components/tools/mcp/detail/tool-item.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/tool-item.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/detail/tool-item.spec.tsx rename to web/app/components/tools/mcp/detail/__tests__/tool-item.spec.tsx index aa04422b48..edbbf3e9a3 100644 --- a/web/app/components/tools/mcp/detail/tool-item.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/tool-item.spec.tsx @@ -1,7 +1,7 @@ import type { Tool } from '@/app/components/tools/types' import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import MCPToolItem from './tool-item' +import MCPToolItem from '../tool-item' describe('MCPToolItem', () => { const createMockTool = (overrides = {}): Tool => ({ diff --git a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts similarity index 99% rename from web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts rename to web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts index 72520e11d1..f44e14d608 100644 --- a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts +++ b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts @@ -3,7 +3,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types' import { act, renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { MCPAuthMethod } from '@/app/components/tools/types' -import { isValidServerID, isValidUrl, useMCPModalForm } from './use-mcp-modal-form' +import { isValidServerID, isValidUrl, useMCPModalForm } from '../use-mcp-modal-form' // Mock the API service vi.mock('@/service/common', () => ({ diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts similarity index 99% rename from web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts rename to web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts index b36f724857..a11365e445 100644 --- a/web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts +++ b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts @@ -6,7 +6,7 @@ import { act, renderHook } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' -import { useMCPServiceCardState } from './use-mcp-service-card' +import { useMCPServiceCardState } from '../use-mcp-service-card' // Mutable mock data for MCP server detail let mockMCPServerDetailData: { diff --git a/web/app/components/tools/mcp/sections/authentication-section.spec.tsx b/web/app/components/tools/mcp/sections/__tests__/authentication-section.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/sections/authentication-section.spec.tsx rename to web/app/components/tools/mcp/sections/__tests__/authentication-section.spec.tsx index ec5c8f0443..f5ed16f21d 100644 --- a/web/app/components/tools/mcp/sections/authentication-section.spec.tsx +++ b/web/app/components/tools/mcp/sections/__tests__/authentication-section.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import AuthenticationSection from './authentication-section' +import AuthenticationSection from '../authentication-section' describe('AuthenticationSection', () => { const defaultProps = { diff --git a/web/app/components/tools/mcp/sections/configurations-section.spec.tsx b/web/app/components/tools/mcp/sections/__tests__/configurations-section.spec.tsx similarity index 98% rename from web/app/components/tools/mcp/sections/configurations-section.spec.tsx rename to web/app/components/tools/mcp/sections/__tests__/configurations-section.spec.tsx index 16e64d206e..4b6bc4009e 100644 --- a/web/app/components/tools/mcp/sections/configurations-section.spec.tsx +++ b/web/app/components/tools/mcp/sections/__tests__/configurations-section.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import ConfigurationsSection from './configurations-section' +import ConfigurationsSection from '../configurations-section' describe('ConfigurationsSection', () => { const defaultProps = { diff --git a/web/app/components/tools/mcp/sections/headers-section.spec.tsx b/web/app/components/tools/mcp/sections/__tests__/headers-section.spec.tsx similarity index 99% rename from web/app/components/tools/mcp/sections/headers-section.spec.tsx rename to web/app/components/tools/mcp/sections/__tests__/headers-section.spec.tsx index ae58e6cec5..b71ba0ca04 100644 --- a/web/app/components/tools/mcp/sections/headers-section.spec.tsx +++ b/web/app/components/tools/mcp/sections/__tests__/headers-section.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import HeadersSection from './headers-section' +import HeadersSection from '../headers-section' describe('HeadersSection', () => { const defaultProps = { diff --git a/web/app/components/tools/provider/custom-create-card.spec.tsx b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx similarity index 98% rename from web/app/components/tools/provider/custom-create-card.spec.tsx rename to web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx index 5bfe3c00c0..3643b769f7 100644 --- a/web/app/components/tools/provider/custom-create-card.spec.tsx +++ b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx @@ -1,8 +1,8 @@ -import type { CustomCollectionBackend } from '../types' +import type { CustomCollectionBackend } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AuthType } from '../types' -import CustomCreateCard from './custom-create-card' +import { AuthType } from '../../types' +import CustomCreateCard from '../custom-create-card' // Mock workspace manager state let mockIsWorkspaceManager = true diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx new file mode 100644 index 0000000000..f2d47f8e43 --- /dev/null +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -0,0 +1,713 @@ +import type { Collection } from '../../types' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthType, CollectionType } from '../../types' +import ProviderDetail from '../detail' + +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('tools.auth.unauthorized')).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('tools.auth.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('tools.createTool.editAction')).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('tools.openInStudio')).toBeInTheDocument() + expect(screen.getByText('tools.createTool.editAction')).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('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + 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') + }) + }) + }) + + describe('BuiltIn Auth Flow', () => { + it('opens ConfigCredential when clicking auth button for builtIn type', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: false })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + expect(screen.getByTestId('config-credential')).toBeInTheDocument() + }) + + it('saves credentials and refreshes data', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: false })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + await act(async () => { + fireEvent.click(screen.getByTestId('credential-save')) + }) + await waitFor(() => { + expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test-collection', { key: 'val' }) + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + it('removes credentials and refreshes data', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: false })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + await act(async () => { + fireEvent.click(screen.getByTestId('credential-remove')) + }) + await waitFor(() => { + expect(mockRemoveBuiltInToolCredential).toHaveBeenCalledWith('test-collection') + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + it('opens auth modal from Authorized button for builtIn type', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: true })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.authorized')) + expect(screen.getByTestId('config-credential')).toBeInTheDocument() + }) + }) + + describe('Model Auth Flow', () => { + it('calls onRefreshData via model modal onSaveCallback', async () => { + 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('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + const call = mockSetShowModelModal.mock.calls[0][0] + act(() => { + call.onSaveCallback() + }) + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + describe('Custom Collection Operations', () => { + it('sets api_key_header_prefix when auth_type is apiKey and has value', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { + auth_type: AuthType.apiKey, + api_key_value: 'secret-key', + }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(mockFetchCustomCollection).toHaveBeenCalled() + }) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + }) + + it('opens edit modal and saves custom collection', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { auth_type: 'none' }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByTestId('edit-save')) + }) + await waitFor(() => { + expect(mockUpdateCustomCollection).toHaveBeenCalledWith({ labels: ['test'] }) + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + it('removes custom collection via delete confirmation', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { auth_type: 'none' }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + fireEvent.click(screen.getByTestId('edit-remove')) + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-btn')) + }) + await waitFor(() => { + expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test-collection') + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + }) + + describe('Workflow Collection Operations', () => { + it('displays workflow tool parameters', async () => { + mockFetchWorkflowToolDetail.mockResolvedValue({ + workflow_app_id: 'wf-123', + workflow_tool_id: 'wt-456', + tool: { + parameters: [ + { name: 'query', type: 'string', llm_description: 'Search query', form: 'llm', required: true }, + { name: 'limit', type: 'number', llm_description: 'Max results', form: 'form', required: false }, + ], + labels: ['search'], + }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.workflow })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('query')).toBeInTheDocument() + expect(screen.getByText('string')).toBeInTheDocument() + expect(screen.getByText('Search query')).toBeInTheDocument() + expect(screen.getByText('limit')).toBeInTheDocument() + }) + }) + + it('saves workflow tool via workflow modal', async () => { + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.workflow })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByTestId('wf-save')) + }) + await waitFor(() => { + expect(mockSaveWorkflowToolProvider).toHaveBeenCalledWith({ name: 'test' }) + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + + it('removes workflow tool via delete confirmation', async () => { + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.workflow })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + fireEvent.click(screen.getByTestId('wf-remove')) + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-btn')) + }) + await waitFor(() => { + expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-id') + expect(mockOnRefreshData).toHaveBeenCalled() + }) + }) + }) + + describe('Modal Close Actions', () => { + it('closes ConfigCredential when cancel is clicked', async () => { + render( + <ProviderDetail + collection={createMockCollection({ allow_delete: true, is_team_authorization: false })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.auth.unauthorized')) + expect(screen.getByTestId('config-credential')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('credential-cancel')) + expect(screen.queryByTestId('config-credential')).not.toBeInTheDocument() + }) + + it('closes EditCustomToolModal via onHide', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { auth_type: 'none' }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('edit-close')) + expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument() + }) + + it('closes WorkflowToolModal via onHide', async () => { + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.workflow })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('wf-close')) + expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument() + }) + }) + + describe('Delete Confirmation', () => { + it('cancels delete confirmation', async () => { + mockFetchCustomCollection.mockResolvedValue({ + credentials: { auth_type: 'none' }, + }) + render( + <ProviderDetail + collection={createMockCollection({ type: CollectionType.custom })} + onHide={mockOnHide} + onRefreshData={mockOnRefreshData} + />, + ) + await waitFor(() => { + expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('tools.createTool.editAction')) + fireEvent.click(screen.getByTestId('edit-remove')) + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('cancel-btn')) + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/tools/provider/empty.spec.tsx b/web/app/components/tools/provider/__tests__/empty.spec.tsx similarity index 98% rename from web/app/components/tools/provider/empty.spec.tsx rename to web/app/components/tools/provider/__tests__/empty.spec.tsx index 7d0bedbd12..7484f99895 100644 --- a/web/app/components/tools/provider/empty.spec.tsx +++ b/web/app/components/tools/provider/__tests__/empty.spec.tsx @@ -2,9 +2,9 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import the mock to control it in tests import useTheme from '@/hooks/use-theme' -import { ToolTypeEnum } from '../../workflow/block-selector/types' +import { ToolTypeEnum } from '../../../workflow/block-selector/types' -import Empty from './empty' +import Empty from '../empty' // Mock useTheme hook vi.mock('@/hooks/use-theme', () => ({ diff --git a/web/app/components/tools/provider/tool-item.spec.tsx b/web/app/components/tools/provider/__tests__/tool-item.spec.tsx similarity index 99% rename from web/app/components/tools/provider/tool-item.spec.tsx rename to web/app/components/tools/provider/__tests__/tool-item.spec.tsx index e2771a0086..d32cf80807 100644 --- a/web/app/components/tools/provider/tool-item.spec.tsx +++ b/web/app/components/tools/provider/__tests__/tool-item.spec.tsx @@ -1,7 +1,7 @@ -import type { Collection, Tool } from '../types' +import type { Collection, Tool } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import ToolItem from './tool-item' +import ToolItem from '../tool-item' // Mock useLocale hook vi.mock('@/context/i18n', () => ({ diff --git a/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx b/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx new file mode 100644 index 0000000000..00b583b32c --- /dev/null +++ b/web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx @@ -0,0 +1,188 @@ +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('@/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('tools.auth.setupModalTitle') + }) + + 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('common.operation.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('common.operation.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('common.operation.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('common.operation.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') + }) + }) +}) diff --git a/web/app/components/tools/utils/__tests__/index.spec.ts b/web/app/components/tools/utils/__tests__/index.spec.ts new file mode 100644 index 0000000000..829846bc86 --- /dev/null +++ b/web/app/components/tools/utils/__tests__/index.spec.ts @@ -0,0 +1,82 @@ +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]) + }) + }) +}) diff --git a/web/app/components/tools/utils/__tests__/to-form-schema.spec.ts b/web/app/components/tools/utils/__tests__/to-form-schema.spec.ts new file mode 100644 index 0000000000..19ae318b84 --- /dev/null +++ b/web/app/components/tools/utils/__tests__/to-form-schema.spec.ts @@ -0,0 +1,408 @@ +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) + }) + }) +}) diff --git a/web/app/components/tools/workflow-tool/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx similarity index 99% rename from web/app/components/tools/workflow-tool/configure-button.spec.tsx rename to web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx index 659aeb4a49..eb646fd8c3 100644 --- a/web/app/components/tools/workflow-tool/configure-button.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx @@ -1,13 +1,13 @@ -import type { WorkflowToolModalPayload } from './index' +import type { WorkflowToolModalPayload } from '../index' import type { WorkflowToolProviderResponse } from '@/app/components/tools/types' import type { InputVar, Variable } from '@/app/components/workflow/types' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { InputVarType, VarType } from '@/app/components/workflow/types' -import WorkflowToolConfigureButton from './configure-button' -import WorkflowToolAsModal from './index' -import MethodSelector from './method-selector' +import WorkflowToolConfigureButton from '../configure-button' +import WorkflowToolAsModal from '../index' +import MethodSelector from '../method-selector' // Mock Next.js navigation const mockPush = vi.fn() diff --git a/web/app/components/tools/workflow-tool/method-selector.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx similarity index 99% rename from web/app/components/tools/workflow-tool/method-selector.spec.tsx rename to web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx index bbdbe5b629..8fe4037231 100644 --- a/web/app/components/tools/workflow-tool/method-selector.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx @@ -2,7 +2,7 @@ import type { ComponentProps } from 'react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import MethodSelector from './method-selector' +import MethodSelector from '../method-selector' // Test utilities const defaultProps: ComponentProps<typeof MethodSelector> = { diff --git a/web/app/components/tools/workflow-tool/confirm-modal/index.spec.tsx b/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/tools/workflow-tool/confirm-modal/index.spec.tsx rename to web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx index a03860d952..d28064ef0c 100644 --- a/web/app/components/tools/workflow-tool/confirm-modal/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import ConfirmModal from './index' +import ConfirmModal from '../index' // Test utilities const defaultProps = { diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index e49d1d8d23..a2c0cb0d94 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -4827,14 +4827,6 @@ "count": 1 } }, - "app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx": { - "ts/no-explicit-any": { - "count": 3 - }, - "unused-imports/no-unused-vars": { - "count": 2 - } - }, "app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 @@ -4893,11 +4885,6 @@ "count": 2 } }, - "app/components/plugins/marketplace/sort-dropdown/index.spec.tsx": { - "unused-imports/no-unused-vars": { - "count": 1 - } - }, "app/components/plugins/marketplace/sort-dropdown/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 3 @@ -5079,14 +5066,6 @@ "count": 2 } }, - "app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 5 - }, - "unused-imports/no-unused-vars": { - "count": 2 - } - }, "app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 3 @@ -5217,16 +5196,6 @@ "count": 3 } }, - "app/components/plugins/plugin-item/action.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "app/components/plugins/plugin-item/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 10 - } - }, "app/components/plugins/plugin-item/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 7 @@ -5235,11 +5204,6 @@ "count": 1 } }, - "app/components/plugins/plugin-mutation-model/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/plugins/plugin-mutation-model/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -5258,11 +5222,6 @@ "count": 1 } }, - "app/components/plugins/plugin-page/empty/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 7 - } - }, "app/components/plugins/plugin-page/empty/index.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 2 @@ -5289,31 +5248,16 @@ "count": 2 } }, - "app/components/plugins/plugin-page/list/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 } }, - "app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/plugins/provider-card.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 } }, - "app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 7 - } - }, "app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 4 @@ -5352,11 +5296,6 @@ "count": 1 } }, - "app/components/plugins/reference-setting-modal/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 7 - } - }, "app/components/plugins/reference-setting-modal/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -5382,11 +5321,6 @@ "count": 1 } }, - "app/components/plugins/update-plugin/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, "app/components/plugins/update-plugin/plugin-version-picker.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 3 diff --git a/web/test/i18n-mock.ts b/web/test/i18n-mock.ts index 20e7a22eef..39f97db543 100644 --- a/web/test/i18n-mock.ts +++ b/web/test/i18n-mock.ts @@ -31,20 +31,31 @@ export function createTFunction(translations: TranslationMap, defaultNs?: string /** * Create useTranslation mock with optional custom translations * + * Caches t functions by defaultNs so the same reference is returned + * across renders, preventing infinite re-render loops when components + * include t in useEffect/useMemo dependency arrays. + * * @example * vi.mock('react-i18next', () => createUseTranslationMock({ * 'operation.confirm': 'Confirm', * })) */ export function createUseTranslationMock(translations: TranslationMap = {}) { + const tCache = new Map<string, ReturnType<typeof createTFunction>>() + const i18n = { + language: 'en', + changeLanguage: vi.fn(), + } return { - useTranslation: (defaultNs?: string) => ({ - t: createTFunction(translations, defaultNs), - i18n: { - language: 'en', - changeLanguage: vi.fn(), - }, - }), + useTranslation: (defaultNs?: string) => { + const cacheKey = defaultNs ?? '' + if (!tCache.has(cacheKey)) + tCache.set(cacheKey, createTFunction(translations, defaultNs)) + return { + t: tCache.get(cacheKey)!, + i18n, + } + }, } } From 80e6312807f4a2c5bf35c511078901f6cf4f7297 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 12 Feb 2026 10:05:06 +0800 Subject: [PATCH 05/18] test: add comprehensive unit and integration tests for billing components (#32227) Co-authored-by: CodingOnStar <hanxujiang@dify.com> --- .../billing/billing-integration.test.tsx | 991 ++++++++++++++++++ .../billing/cloud-plan-payment-flow.test.tsx | 296 ++++++ .../education-verification-flow.test.tsx | 318 ++++++ .../billing/partner-stack-flow.test.tsx | 326 ++++++ .../billing/pricing-modal-flow.test.tsx | 327 ++++++ .../billing/self-hosted-plan-flow.test.tsx | 225 ++++ .../billing/__tests__/config.spec.ts | 141 +++ .../{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/modal.spec.tsx | 15 +- .../{ => __tests__}/usage.spec.tsx | 18 +- .../{ => __tests__}/index.spec.tsx | 23 +- .../{ => __tests__}/index.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 42 +- .../{ => __tests__}/index.spec.tsx | 23 +- .../{ => __tests__}/use-ps-info.spec.tsx | 105 +- .../{ => __tests__}/index.spec.tsx | 15 +- .../plan/{ => __tests__}/index.spec.tsx | 72 +- .../{ => __tests__}/enterprise.spec.tsx | 2 +- .../assets/{ => __tests__}/index.spec.tsx | 10 +- .../{ => __tests__}/professional.spec.tsx | 2 +- .../assets/{ => __tests__}/sandbox.spec.tsx | 2 +- .../plan/assets/{ => __tests__}/team.spec.tsx | 2 +- .../pricing/{ => __tests__}/footer.spec.tsx | 13 +- .../pricing/{ => __tests__}/header.spec.tsx | 39 +- .../pricing/{ => __tests__}/index.spec.tsx | 57 +- .../assets/__tests__/components.spec.tsx | 81 ++ .../assets/{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/index.spec.tsx | 48 +- .../plan-range-switcher.spec.tsx | 48 +- .../{ => __tests__}/tab.spec.tsx | 14 +- .../plans/{ => __tests__}/index.spec.tsx | 16 +- .../{ => __tests__}/button.spec.tsx | 9 +- .../{ => __tests__}/index.spec.tsx | 177 +++- .../list/{ => __tests__}/index.spec.tsx | 4 +- .../list/item/{ => __tests__}/index.spec.tsx | 11 +- .../item/{ => __tests__}/tooltip.spec.tsx | 9 +- .../{ => __tests__}/button.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 52 +- .../list/__tests__/index.spec.tsx | 20 + .../list/__tests__/item.spec.tsx | 35 + .../self-hosted-plan-item/list/index.spec.tsx | 26 - .../self-hosted-plan-item/list/item.spec.tsx | 12 - .../{ => __tests__}/index.spec.tsx | 39 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 54 +- .../{ => __tests__}/index.spec.tsx | 142 +-- .../usage-info/__tests__/apps-info.spec.tsx | 67 ++ .../usage-info/{ => __tests__}/index.spec.tsx | 4 +- .../vector-space-info.spec.tsx | 6 +- .../billing/usage-info/apps-info.spec.tsx | 35 - .../utils/{ => __tests__}/index.spec.ts | 6 +- .../{ => __tests__}/index.spec.tsx | 28 +- web/eslint-suppressions.json | 15 - 53 files changed, 3431 insertions(+), 625 deletions(-) create mode 100644 web/__tests__/billing/billing-integration.test.tsx create mode 100644 web/__tests__/billing/cloud-plan-payment-flow.test.tsx create mode 100644 web/__tests__/billing/education-verification-flow.test.tsx create mode 100644 web/__tests__/billing/partner-stack-flow.test.tsx create mode 100644 web/__tests__/billing/pricing-modal-flow.test.tsx create mode 100644 web/__tests__/billing/self-hosted-plan-flow.test.tsx create mode 100644 web/app/components/billing/__tests__/config.spec.ts rename web/app/components/billing/annotation-full/{ => __tests__}/index.spec.tsx (86%) rename web/app/components/billing/annotation-full/{ => __tests__}/modal.spec.tsx (92%) rename web/app/components/billing/annotation-full/{ => __tests__}/usage.spec.tsx (70%) rename web/app/components/billing/apps-full-in-dialog/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/billing/billing-page/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/billing/header-billing-btn/{ => __tests__}/index.spec.tsx (65%) rename web/app/components/billing/partner-stack/{ => __tests__}/index.spec.tsx (57%) rename web/app/components/billing/partner-stack/{ => __tests__}/use-ps-info.spec.tsx (60%) rename web/app/components/billing/plan-upgrade-modal/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/billing/plan/{ => __tests__}/index.spec.tsx (69%) rename web/app/components/billing/plan/assets/{ => __tests__}/enterprise.spec.tsx (99%) rename web/app/components/billing/plan/assets/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/billing/plan/assets/{ => __tests__}/professional.spec.tsx (99%) rename web/app/components/billing/plan/assets/{ => __tests__}/sandbox.spec.tsx (99%) rename web/app/components/billing/plan/assets/{ => __tests__}/team.spec.tsx (99%) rename web/app/components/billing/pricing/{ => __tests__}/footer.spec.tsx (87%) rename web/app/components/billing/pricing/{ => __tests__}/header.spec.tsx (55%) rename web/app/components/billing/pricing/{ => __tests__}/index.spec.tsx (66%) create mode 100644 web/app/components/billing/pricing/assets/__tests__/components.spec.tsx rename web/app/components/billing/pricing/assets/{ => __tests__}/index.spec.tsx (91%) rename web/app/components/billing/pricing/plan-switcher/{ => __tests__}/index.spec.tsx (65%) rename web/app/components/billing/pricing/plan-switcher/{ => __tests__}/plan-range-switcher.spec.tsx (50%) rename web/app/components/billing/pricing/plan-switcher/{ => __tests__}/tab.spec.tsx (88%) rename web/app/components/billing/pricing/plans/{ => __tests__}/index.spec.tsx (86%) rename web/app/components/billing/pricing/plans/cloud-plan-item/{ => __tests__}/button.spec.tsx (89%) rename web/app/components/billing/pricing/plans/cloud-plan-item/{ => __tests__}/index.spec.tsx (52%) rename web/app/components/billing/pricing/plans/cloud-plan-item/list/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/billing/pricing/plans/cloud-plan-item/list/item/{ => __tests__}/index.spec.tsx (89%) rename web/app/components/billing/pricing/plans/cloud-plan-item/list/item/{ => __tests__}/tooltip.spec.tsx (88%) rename web/app/components/billing/pricing/plans/self-hosted-plan-item/{ => __tests__}/button.spec.tsx (94%) rename web/app/components/billing/pricing/plans/self-hosted-plan-item/{ => __tests__}/index.spec.tsx (75%) create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/index.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/item.spec.tsx delete mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.spec.tsx delete mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.spec.tsx rename web/app/components/billing/priority-label/{ => __tests__}/index.spec.tsx (87%) rename web/app/components/billing/progress-bar/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/billing/trigger-events-limit-modal/{ => __tests__}/index.spec.tsx (55%) rename web/app/components/billing/upgrade-btn/{ => __tests__}/index.spec.tsx (79%) create mode 100644 web/app/components/billing/usage-info/__tests__/apps-info.spec.tsx rename web/app/components/billing/usage-info/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/billing/usage-info/{ => __tests__}/vector-space-info.spec.tsx (98%) delete mode 100644 web/app/components/billing/usage-info/apps-info.spec.tsx rename web/app/components/billing/utils/{ => __tests__}/index.spec.ts (98%) rename web/app/components/billing/vector-space-full/{ => __tests__}/index.spec.tsx (69%) diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx new file mode 100644 index 0000000000..4891760df4 --- /dev/null +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -0,0 +1,991 @@ +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 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 { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config' +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 { Plan } from '@/app/components/billing/type' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' +import VectorSpaceFull from '@/app/components/billing/vector-space-full' + +let mockProviderCtx: Record<string, unknown> = {} +let mockAppCtx: Record<string, unknown> = {} +const mockSetShowPricingModal = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() + +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, + } +} + +// Vitest hoists vi.mock() calls, so imports above will use mocked modules + +// ═══════════════════════════════════════════════════════════════════════════ +// 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) + }) +}) diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx new file mode 100644 index 0000000000..e01d9250fd --- /dev/null +++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx @@ -0,0 +1,296 @@ +/** + * Integration test: Cloud Plan Payment Flow + * + * Tests the payment flow for cloud plan items: + * CloudPlanItem → Button click → permission check → fetch URL → redirect + * + * Covers plan comparison, downgrade prevention, monthly/yearly pricing, + * and workspace manager permission enforcement. + */ +import type { BasicPlan } from '@/app/components/billing/type' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { ALL_PLANS } from '@/app/components/billing/config' +import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher' +import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item' +import { Plan } from '@/app/components/billing/type' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockAppCtx: Record<string, unknown> = {} +const mockFetchSubscriptionUrls = vi.fn() +const mockInvoices = vi.fn() +const mockOpenAsyncWindow = vi.fn() +const mockToastNotify = vi.fn() + +// ─── Context mocks ─────────────────────────────────────────────────────────── +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +// ─── Service mocks ─────────────────────────────────────────────────────────── +vi.mock('@/service/billing', () => ({ + fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + billing: { + invoices: () => mockInvoices(), + }, + }, +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => mockOpenAsyncWindow, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (args: unknown) => mockToastNotify(args) }, +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/billing', + useSearchParams: () => new URLSearchParams(), +})) + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const setupAppContext = (overrides: Record<string, unknown> = {}) => { + mockAppCtx = { + isCurrentWorkspaceManager: true, + ...overrides, + } +} + +type RenderCloudPlanItemOptions = { + currentPlan?: BasicPlan + plan?: BasicPlan + planRange?: PlanRange + canPay?: boolean +} + +const renderCloudPlanItem = ({ + currentPlan = Plan.sandbox, + plan = Plan.professional, + planRange = PlanRange.monthly, + canPay = true, +}: RenderCloudPlanItemOptions = {}) => { + return render( + <CloudPlanItem + currentPlan={currentPlan} + plan={plan} + planRange={planRange} + canPay={canPay} + />, + ) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Cloud Plan Payment Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupAppContext() + mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com/checkout' }) + mockInvoices.mockResolvedValue({ url: 'https://billing.example.com/invoices' }) + }) + + // ─── 1. Plan Display ──────────────────────────────────────────────────── + describe('Plan display', () => { + it('should render plan name and description', () => { + renderCloudPlanItem({ plan: Plan.professional }) + + expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.professional\.description/i)).toBeInTheDocument() + }) + + it('should show "Free" price for sandbox plan', () => { + renderCloudPlanItem({ plan: Plan.sandbox }) + + expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument() + }) + + it('should show monthly price for paid plans', () => { + renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.monthly }) + + expect(screen.getByText(`$${ALL_PLANS.professional.price}`)).toBeInTheDocument() + }) + + it('should show yearly discounted price (10 months) and strikethrough original (12 months)', () => { + renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.yearly }) + + const yearlyPrice = ALL_PLANS.professional.price * 10 + const originalPrice = ALL_PLANS.professional.price * 12 + + expect(screen.getByText(`$${yearlyPrice}`)).toBeInTheDocument() + expect(screen.getByText(`$${originalPrice}`)).toBeInTheDocument() + }) + + it('should show "most popular" badge for professional plan', () => { + renderCloudPlanItem({ plan: Plan.professional }) + + expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument() + }) + + it('should not show "most popular" badge for sandbox or team plans', () => { + const { unmount } = renderCloudPlanItem({ plan: Plan.sandbox }) + expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument() + unmount() + + renderCloudPlanItem({ plan: Plan.team }) + expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument() + }) + }) + + // ─── 2. Button Text Logic ─────────────────────────────────────────────── + describe('Button text logic', () => { + it('should show "Current Plan" when plan matches current plan', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument() + }) + + it('should show "Start for Free" for sandbox plan when not current', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox }) + + expect(screen.getByText(/plansCommon\.startForFree/i)).toBeInTheDocument() + }) + + it('should show "Start Building" for professional plan when not current', () => { + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional }) + + expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument() + }) + + it('should show "Get Started" for team plan when not current', () => { + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team }) + + expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument() + }) + }) + + // ─── 3. Downgrade Prevention ──────────────────────────────────────────── + describe('Downgrade prevention', () => { + it('should disable sandbox button when user is on professional plan (downgrade)', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox }) + + const button = screen.getByRole('button') + expect(button).toBeDisabled() + }) + + it('should disable sandbox and professional buttons when user is on team plan', () => { + const { unmount } = renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.sandbox }) + expect(screen.getByRole('button')).toBeDisabled() + unmount() + + renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.professional }) + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should not disable current paid plan button (for invoice management)', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + const button = screen.getByRole('button') + expect(button).not.toBeDisabled() + }) + + it('should enable higher-tier plan buttons for upgrade', () => { + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team }) + + const button = screen.getByRole('button') + expect(button).not.toBeDisabled() + }) + }) + + // ─── 4. Payment URL Flow ──────────────────────────────────────────────── + describe('Payment URL flow', () => { + it('should call fetchSubscriptionUrls with plan and "month" for monthly range', async () => { + const user = userEvent.setup() + // Simulate clicking on a professional plan button (user is on sandbox) + renderCloudPlanItem({ + currentPlan: Plan.sandbox, + plan: Plan.professional, + planRange: PlanRange.monthly, + }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month') + }) + }) + + it('should call fetchSubscriptionUrls with plan and "year" for yearly range', async () => { + const user = userEvent.setup() + renderCloudPlanItem({ + currentPlan: Plan.sandbox, + plan: Plan.team, + planRange: PlanRange.yearly, + }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year') + }) + }) + + it('should open invoice management for current paid plan', async () => { + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockOpenAsyncWindow).toHaveBeenCalled() + }) + // Should NOT call fetchSubscriptionUrls (invoice, not subscription) + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + }) + + it('should not do anything when clicking on sandbox free plan button', async () => { + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.sandbox }) + + const button = screen.getByRole('button') + await user.click(button) + + // Wait a tick and verify no actions were taken + await waitFor(() => { + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + expect(mockOpenAsyncWindow).not.toHaveBeenCalled() + }) + }) + }) + + // ─── 5. Permission Check ──────────────────────────────────────────────── + describe('Permission check', () => { + it('should show error toast when non-manager clicks upgrade button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) + }) + // Should not proceed with payment + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx new file mode 100644 index 0000000000..8c35cd9a8c --- /dev/null +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -0,0 +1,318 @@ +/** + * Integration test: Education Verification Flow + * + * Tests the education plan verification flow in PlanComp: + * PlanComp → handleVerify → useEducationVerify → router.push → education-apply + * PlanComp → handleVerify → error → show VerifyStateModal + * + * Also covers education button visibility based on context flags. + */ +import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { defaultPlan } from '@/app/components/billing/config' +import PlanComp from '@/app/components/billing/plan' +import { Plan } from '@/app/components/billing/type' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockProviderCtx: Record<string, unknown> = {} +let mockAppCtx: Record<string, unknown> = {} +const mockSetShowPricingModal = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() +const mockRouterPush = vi.fn() +const mockMutateAsync = 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', +})) + +// ─── Service mocks ─────────────────────────────────────────────────────────── +vi.mock('@/service/use-education', () => ({ + useEducationVerify: () => ({ + mutateAsync: mockMutateAsync, + isPending: false, + }), +})) + +vi.mock('@/service/use-billing', () => ({ + useBillingUrl: () => ({ + data: 'https://billing.example.com', + isFetching: false, + refetch: vi.fn(), + }), +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +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, title, content, email, showLink }: { + isShow: boolean + title?: string + content?: string + email?: string + showLink?: boolean + }) => + isShow + ? ( + <div data-testid="verify-state-modal"> + {title && <span data-testid="modal-title">{title}</span>} + {content && <span data-testid="modal-content">{content}</span>} + {email && <span data-testid="modal-email">{email}</span>} + {showLink && <span data-testid="modal-show-link">link</span>} + </div> + ) + : null, +})) + +// ─── 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 setupContexts = ( + planOverrides: PlanOverrides = {}, + providerOverrides: Record<string, unknown> = {}, + appOverrides: Record<string, unknown> = {}, +) => { + mockProviderCtx = { + plan: createPlanData(planOverrides), + enableBilling: true, + isFetchedPlan: true, + enableEducationPlan: false, + isEducationAccount: false, + allowRefreshEducationVerify: false, + ...providerOverrides, + } + mockAppCtx = { + isCurrentWorkspaceManager: true, + userProfile: { email: 'student@university.edu' }, + langGeniusVersionInfo: { current_version: '1.0.0' }, + ...appOverrides, + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Education Verification Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupContexts() + }) + + // ─── 1. Education Button Visibility ───────────────────────────────────── + describe('Education button visibility', () => { + it('should not show verify button when enableEducationPlan is false', () => { + setupContexts({}, { enableEducationPlan: false }) + + render(<PlanComp loc="test" />) + + expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument() + }) + + it('should show verify button when enableEducationPlan is true and not yet verified', () => { + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + + render(<PlanComp loc="test" />) + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + }) + + it('should not show verify button when already verified and not about to expire', () => { + setupContexts({}, { + enableEducationPlan: true, + isEducationAccount: true, + allowRefreshEducationVerify: false, + }) + + render(<PlanComp loc="test" />) + + expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument() + }) + + it('should show verify button when about to expire (allowRefreshEducationVerify is true)', () => { + setupContexts({}, { + enableEducationPlan: true, + isEducationAccount: true, + allowRefreshEducationVerify: true, + }) + + render(<PlanComp loc="test" />) + + // Shown because isAboutToExpire = allowRefreshEducationVerify = true + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + }) + }) + + // ─── 2. Successful Verification Flow ──────────────────────────────────── + describe('Successful verification flow', () => { + it('should navigate to education-apply with token on successful verification', async () => { + mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' }) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render(<PlanComp loc="test" />) + + const verifyButton = screen.getByText(/toVerified/i) + await user.click(verifyButton) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledTimes(1) + expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123') + }) + }) + + it('should remove education verifying flag from localStorage on success', async () => { + mockMutateAsync.mockResolvedValue({ token: 'token-xyz' }) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render(<PlanComp loc="test" />) + + await user.click(screen.getByText(/toVerified/i)) + + await waitFor(() => { + expect(localStorage.removeItem).toHaveBeenCalledWith('educationVerifying') + }) + }) + }) + + // ─── 3. Failed Verification Flow ──────────────────────────────────────── + describe('Failed verification flow', () => { + it('should show VerifyStateModal with rejection info on error', async () => { + mockMutateAsync.mockRejectedValue(new Error('Verification failed')) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render(<PlanComp loc="test" />) + + // Modal should not be visible initially + expect(screen.queryByTestId('verify-state-modal')).not.toBeInTheDocument() + + const verifyButton = screen.getByText(/toVerified/i) + await user.click(verifyButton) + + // Modal should appear after verification failure + await waitFor(() => { + expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument() + }) + + // Modal should display rejection title and content + expect(screen.getByTestId('modal-title')).toHaveTextContent(/rejectTitle/i) + expect(screen.getByTestId('modal-content')).toHaveTextContent(/rejectContent/i) + }) + + it('should show email and link in VerifyStateModal', async () => { + mockMutateAsync.mockRejectedValue(new Error('fail')) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render(<PlanComp loc="test" />) + + await user.click(screen.getByText(/toVerified/i)) + + await waitFor(() => { + expect(screen.getByTestId('modal-email')).toHaveTextContent('student@university.edu') + expect(screen.getByTestId('modal-show-link')).toBeInTheDocument() + }) + }) + + it('should not redirect on verification failure', async () => { + mockMutateAsync.mockRejectedValue(new Error('fail')) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render(<PlanComp loc="test" />) + + await user.click(screen.getByText(/toVerified/i)) + + await waitFor(() => { + expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument() + }) + + // Should NOT navigate + expect(mockRouterPush).not.toHaveBeenCalled() + }) + }) + + // ─── 4. Education + Upgrade Coexistence ───────────────────────────────── + describe('Education and upgrade button coexistence', () => { + it('should show both education verify and upgrade buttons for sandbox user', () => { + setupContexts( + { type: Plan.sandbox }, + { enableEducationPlan: true, isEducationAccount: false }, + ) + + render(<PlanComp loc="test" />) + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should not show upgrade button for enterprise plan', () => { + setupContexts( + { type: Plan.enterprise }, + { enableEducationPlan: true, isEducationAccount: false }, + ) + + render(<PlanComp loc="test" />) + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument() + }) + + it('should show team plan with plain upgrade button and education button', () => { + setupContexts( + { type: Plan.team }, + { enableEducationPlan: true, isEducationAccount: false }, + ) + + render(<PlanComp loc="test" />) + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/billing/partner-stack-flow.test.tsx b/web/__tests__/billing/partner-stack-flow.test.tsx new file mode 100644 index 0000000000..4f265478cd --- /dev/null +++ b/web/__tests__/billing/partner-stack-flow.test.tsx @@ -0,0 +1,326 @@ +/** + * Integration test: Partner Stack Flow + * + * Tests the PartnerStack integration: + * PartnerStack component → usePSInfo hook → cookie management → bind API call + * + * Covers URL param reading, cookie persistence, API bind on mount, + * cookie cleanup after successful bind, and error handling for 400 status. + */ +import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react' +import Cookies from 'js-cookie' +import * as React from 'react' +import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info' +import { PARTNER_STACK_CONFIG } from '@/config' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockSearchParams = new URLSearchParams() +const mockMutateAsync = vi.fn() + +// ─── Module mocks ──────────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useSearchParams: () => mockSearchParams, + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/', +})) + +vi.mock('@/service/use-billing', () => ({ + useBindPartnerStackInfo: () => ({ + mutateAsync: mockMutateAsync, + }), + useBillingUrl: () => ({ + data: '', + isFetching: false, + refetch: vi.fn(), + }), +})) + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal<Record<string, unknown>>() + return { + ...actual, + IS_CLOUD_EDITION: true, + PARTNER_STACK_CONFIG: { + cookieName: 'partner_stack_info', + saveCookieDays: 90, + }, + } +}) + +// ─── Cookie helpers ────────────────────────────────────────────────────────── +const getCookieData = () => { + const raw = Cookies.get(PARTNER_STACK_CONFIG.cookieName) + if (!raw) + return null + try { + return JSON.parse(raw) + } + catch { + return null + } +} + +const setCookieData = (data: Record<string, string>) => { + Cookies.set(PARTNER_STACK_CONFIG.cookieName, JSON.stringify(data)) +} + +const clearCookie = () => { + Cookies.remove(PARTNER_STACK_CONFIG.cookieName) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Partner Stack Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + clearCookie() + mockSearchParams = new URLSearchParams() + mockMutateAsync.mockResolvedValue({}) + }) + + // ─── 1. URL Param Reading ─────────────────────────────────────────────── + describe('URL param reading', () => { + it('should read ps_partner_key and ps_xid from URL search params', () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'partner-123', + ps_xid: 'click-456', + }) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('partner-123') + expect(result.current.psClickId).toBe('click-456') + }) + + it('should fall back to cookie when URL params are not present', () => { + setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' }) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('cookie-partner') + expect(result.current.psClickId).toBe('cookie-click') + }) + + it('should prefer URL params over cookie values', () => { + setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'url-partner', + ps_xid: 'url-click', + }) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('url-partner') + expect(result.current.psClickId).toBe('url-click') + }) + + it('should return null for both values when no params and no cookie', () => { + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBeUndefined() + expect(result.current.psClickId).toBeUndefined() + }) + }) + + // ─── 2. Cookie Persistence (saveOrUpdate) ─────────────────────────────── + describe('Cookie persistence via saveOrUpdate', () => { + it('should save PS info to cookie when URL params provide new values', () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'new-partner', + ps_xid: 'new-click', + }) + + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + const cookieData = getCookieData() + expect(cookieData).toEqual({ + partnerKey: 'new-partner', + clickId: 'new-click', + }) + }) + + it('should not update cookie when values have not changed', () => { + setCookieData({ partnerKey: 'same-partner', clickId: 'same-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'same-partner', + ps_xid: 'same-click', + }) + + const cookieSetSpy = vi.spyOn(Cookies, 'set') + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + // Should not call set because values haven't changed + expect(cookieSetSpy).not.toHaveBeenCalled() + cookieSetSpy.mockRestore() + }) + + it('should not save to cookie when partner key is missing', () => { + mockSearchParams = new URLSearchParams({ + ps_xid: 'click-only', + }) + + const cookieSetSpy = vi.spyOn(Cookies, 'set') + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + expect(cookieSetSpy).not.toHaveBeenCalled() + cookieSetSpy.mockRestore() + }) + + it('should not save to cookie when click ID is missing', () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'partner-only', + }) + + const cookieSetSpy = vi.spyOn(Cookies, 'set') + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + expect(cookieSetSpy).not.toHaveBeenCalled() + cookieSetSpy.mockRestore() + }) + }) + + // ─── 3. Bind API Flow ────────────────────────────────────────────────── + describe('Bind API flow', () => { + it('should call mutateAsync with partnerKey and clickId on bind', async () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'bind-partner', + ps_xid: 'bind-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + expect(mockMutateAsync).toHaveBeenCalledWith({ + partnerKey: 'bind-partner', + clickId: 'bind-click', + }) + }) + + it('should remove cookie after successful bind', async () => { + setCookieData({ partnerKey: 'rm-partner', clickId: 'rm-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'rm-partner', + ps_xid: 'rm-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + // Cookie should be removed after successful bind + expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() + }) + + it('should remove cookie on 400 error (already bound)', async () => { + mockMutateAsync.mockRejectedValue({ status: 400 }) + setCookieData({ partnerKey: 'err-partner', clickId: 'err-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'err-partner', + ps_xid: 'err-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + // Cookie should be removed even on 400 + expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() + }) + + it('should not remove cookie on non-400 errors', async () => { + mockMutateAsync.mockRejectedValue({ status: 500 }) + setCookieData({ partnerKey: 'keep-partner', clickId: 'keep-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'keep-partner', + ps_xid: 'keep-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + // Cookie should still exist for non-400 errors + const cookieData = getCookieData() + expect(cookieData).toBeTruthy() + }) + + it('should not call bind when partner key is missing', async () => { + mockSearchParams = new URLSearchParams({ + ps_xid: 'click-only', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + expect(mockMutateAsync).not.toHaveBeenCalled() + }) + + it('should not call bind a second time (idempotency)', async () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'partner-once', + ps_xid: 'click-once', + }) + + const { result } = renderHook(() => usePSInfo()) + + // First bind + await act(async () => { + await result.current.bind() + }) + expect(mockMutateAsync).toHaveBeenCalledTimes(1) + + // Second bind should be skipped (hasBind = true) + await act(async () => { + await result.current.bind() + }) + expect(mockMutateAsync).toHaveBeenCalledTimes(1) + }) + }) + + // ─── 4. PartnerStack Component Mount ──────────────────────────────────── + describe('PartnerStack component mount behavior', () => { + it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'mount-partner', + ps_xid: 'mount-click', + }) + + // Use lazy import so the mocks are applied + const { default: PartnerStack } = await import('@/app/components/billing/partner-stack') + + render(<PartnerStack />) + + // The component calls saveOrUpdate and bind in useEffect + await waitFor(() => { + // Bind should have been called + expect(mockMutateAsync).toHaveBeenCalledWith({ + partnerKey: 'mount-partner', + clickId: 'mount-click', + }) + }) + + // Cookie should have been saved (saveOrUpdate was called before bind) + // After bind succeeds, cookie is removed + expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() + }) + + it('should render nothing (return null)', async () => { + const { default: PartnerStack } = await import('@/app/components/billing/partner-stack') + + const { container } = render(<PartnerStack />) + + expect(container.innerHTML).toBe('') + }) + }) +}) diff --git a/web/__tests__/billing/pricing-modal-flow.test.tsx b/web/__tests__/billing/pricing-modal-flow.test.tsx new file mode 100644 index 0000000000..6b8fb57f83 --- /dev/null +++ b/web/__tests__/billing/pricing-modal-flow.test.tsx @@ -0,0 +1,327 @@ +/** + * Integration test: Pricing Modal Flow + * + * Tests the full Pricing modal lifecycle: + * Pricing → PlanSwitcher (category + range toggle) → Plans (cloud / self-hosted) + * → CloudPlanItem / SelfHostedPlanItem → Footer + * + * Validates cross-component state propagation when the user switches between + * cloud / self-hosted categories and monthly / yearly plan ranges. + */ +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { ALL_PLANS } from '@/app/components/billing/config' +import Pricing from '@/app/components/billing/pricing' +import { Plan } from '@/app/components/billing/type' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockProviderCtx: Record<string, unknown> = {} +let mockAppCtx: Record<string, unknown> = {} + +// ─── Context mocks ─────────────────────────────────────────────────────────── +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockProviderCtx, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', + useGetPricingPageLanguage: () => 'en', +})) + +// ─── Service mocks ─────────────────────────────────────────────────────────── +vi.mock('@/service/billing', () => ({ + fetchSubscriptionUrls: vi.fn().mockResolvedValue({ url: 'https://pay.example.com' }), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + billing: { + invoices: vi.fn().mockResolvedValue({ url: 'https://invoice.example.com' }), + }, + }, +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/billing', + useSearchParams: () => new URLSearchParams(), +})) + +// ─── External component mocks (lightweight) ───────────────────────────────── +vi.mock('@/app/components/base/icons/src/public/billing', () => ({ + Azure: () => <span data-testid="icon-azure" />, + GoogleCloud: () => <span data-testid="icon-gcloud" />, + AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />, + AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />, +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), + useTheme: () => ({ theme: 'light' }), +})) + +// Self-hosted List uses t() with returnObjects which returns string in mock; +// mock it to avoid deep i18n dependency (unit tests cover this component) +vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({ + default: ({ plan }: { plan: string }) => ( + <div data-testid={`self-hosted-list-${plan}`}>Features</div> + ), +})) + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const defaultPlanData = { + type: Plan.sandbox, + usage: { + buildApps: 1, + teamMembers: 1, + documentsUploadQuota: 0, + vectorSpace: 10, + annotatedResponse: 1, + triggerEvents: 0, + apiRateLimit: 0, + }, + total: { + buildApps: 5, + teamMembers: 1, + documentsUploadQuota: 50, + vectorSpace: 50, + annotatedResponse: 10, + triggerEvents: 3000, + apiRateLimit: 5000, + }, +} + +const setupContexts = (planOverrides: Record<string, unknown> = {}, appOverrides: Record<string, unknown> = {}) => { + mockProviderCtx = { + plan: { ...defaultPlanData, ...planOverrides }, + enableBilling: true, + isFetchedPlan: true, + enableEducationPlan: false, + isEducationAccount: false, + allowRefreshEducationVerify: false, + } + mockAppCtx = { + isCurrentWorkspaceManager: true, + userProfile: { email: 'test@example.com' }, + langGeniusVersionInfo: { current_version: '1.0.0' }, + ...appOverrides, + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Pricing Modal Flow', () => { + const onCancel = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupContexts() + }) + + // ─── 1. Initial Rendering ──────────────────────────────────────────────── + describe('Initial rendering', () => { + it('should render header with close button and footer with pricing link', () => { + render(<Pricing onCancel={onCancel} />) + + // Header close button exists (multiple plan buttons also exist) + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + // Footer pricing link + expect(screen.getByText(/plansCommon\.comparePlanAndFeatures/i)).toBeInTheDocument() + }) + + it('should default to cloud category with three cloud plans', () => { + render(<Pricing onCancel={onCancel} />) + + // Three cloud plans: sandbox, professional, team + expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument() + }) + + it('should show plan range switcher (annual billing toggle) by default for cloud', () => { + render(<Pricing onCancel={onCancel} />) + + expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument() + }) + + it('should show tax tip in footer for cloud category', () => { + render(<Pricing onCancel={onCancel} />) + + // Use exact match to avoid matching taxTipSecond + expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument() + expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument() + }) + }) + + // ─── 2. Category Switching ─────────────────────────────────────────────── + describe('Category switching', () => { + it('should switch to self-hosted plans when clicking self-hosted tab', async () => { + const user = userEvent.setup() + render(<Pricing onCancel={onCancel} />) + + // Click the self-hosted tab + const selfTab = screen.getByText(/plansCommon\.self/i) + await user.click(selfTab) + + // Self-hosted plans should appear + expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument() + + // Cloud plans should disappear + expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument() + }) + + it('should hide plan range switcher for self-hosted category', async () => { + const user = userEvent.setup() + render(<Pricing onCancel={onCancel} />) + + await user.click(screen.getByText(/plansCommon\.self/i)) + + // Annual billing toggle should not be visible + expect(screen.queryByText(/plansCommon\.annualBilling/i)).not.toBeInTheDocument() + }) + + it('should hide tax tip in footer for self-hosted category', async () => { + const user = userEvent.setup() + render(<Pricing onCancel={onCancel} />) + + await user.click(screen.getByText(/plansCommon\.self/i)) + + expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument() + }) + + it('should switch back to cloud plans when clicking cloud tab', async () => { + const user = userEvent.setup() + render(<Pricing onCancel={onCancel} />) + + // Switch to self-hosted + await user.click(screen.getByText(/plansCommon\.self/i)) + expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument() + + // Switch back to cloud + await user.click(screen.getByText(/plansCommon\.cloud/i)) + expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument() + }) + }) + + // ─── 3. Plan Range Switching (Monthly ↔ Yearly) ────────────────────────── + describe('Plan range switching', () => { + it('should show monthly prices by default', () => { + render(<Pricing onCancel={onCancel} />) + + // Professional monthly price: $59 + const proPriceStr = `$${ALL_PLANS.professional.price}` + expect(screen.getByText(proPriceStr)).toBeInTheDocument() + + // Team monthly price: $159 + const teamPriceStr = `$${ALL_PLANS.team.price}` + expect(screen.getByText(teamPriceStr)).toBeInTheDocument() + }) + + it('should show "Free" for sandbox plan regardless of range', () => { + render(<Pricing onCancel={onCancel} />) + + expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument() + }) + + it('should show "most popular" badge only for professional plan', () => { + render(<Pricing onCancel={onCancel} />) + + expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument() + }) + }) + + // ─── 4. Cloud Plan Button States ───────────────────────────────────────── + describe('Cloud plan button states', () => { + it('should show "Current Plan" for the current plan (sandbox)', () => { + setupContexts({ type: Plan.sandbox }) + render(<Pricing onCancel={onCancel} />) + + expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument() + }) + + it('should show specific button text for non-current plans', () => { + setupContexts({ type: Plan.sandbox }) + render(<Pricing onCancel={onCancel} />) + + // Professional button text + expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument() + // Team button text + expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument() + }) + + it('should mark sandbox as "Current Plan" for professional user (enterprise normalized to team)', () => { + setupContexts({ type: Plan.enterprise }) + render(<Pricing onCancel={onCancel} />) + + // Enterprise is normalized to team for display, so team is "Current Plan" + expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument() + }) + }) + + // ─── 5. Self-Hosted Plan Details ───────────────────────────────────────── + describe('Self-hosted plan details', () => { + it('should show cloud provider icons only for premium plan', async () => { + const user = userEvent.setup() + render(<Pricing onCancel={onCancel} />) + + await user.click(screen.getByText(/plansCommon\.self/i)) + + // Premium plan should show Azure and Google Cloud icons + expect(screen.getByTestId('icon-azure')).toBeInTheDocument() + expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument() + }) + + it('should show "coming soon" text for premium plan cloud providers', async () => { + const user = userEvent.setup() + render(<Pricing onCancel={onCancel} />) + + await user.click(screen.getByText(/plansCommon\.self/i)) + + expect(screen.getByText(/plans\.premium\.comingSoon/i)).toBeInTheDocument() + }) + }) + + // ─── 6. Close Handling ─────────────────────────────────────────────────── + describe('Close handling', () => { + it('should call onCancel when pressing ESC key', () => { + render(<Pricing onCancel={onCancel} />) + + // ahooks useKeyPress listens on document for keydown events + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + keyCode: 27, + bubbles: true, + })) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + }) + + // ─── 7. Pricing URL ───────────────────────────────────────────────────── + describe('Pricing page URL', () => { + it('should render pricing link with correct URL', () => { + render(<Pricing onCancel={onCancel} />) + + const link = screen.getByText(/plansCommon\.comparePlanAndFeatures/i) + expect(link.closest('a')).toHaveAttribute( + 'href', + 'https://dify.ai/en/pricing#plans-and-features', + ) + }) + }) +}) diff --git a/web/__tests__/billing/self-hosted-plan-flow.test.tsx b/web/__tests__/billing/self-hosted-plan-flow.test.tsx new file mode 100644 index 0000000000..810d36da8a --- /dev/null +++ b/web/__tests__/billing/self-hosted-plan-flow.test.tsx @@ -0,0 +1,225 @@ +/** + * Integration test: Self-Hosted Plan Flow + * + * Tests the self-hosted plan items: + * SelfHostedPlanItem → Button click → permission check → redirect to external URL + * + * Covers community/premium/enterprise plan rendering, external URL navigation, + * and workspace manager permission enforcement. + */ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config' +import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item' +import { SelfHostedPlan } from '@/app/components/billing/type' + +let mockAppCtx: Record<string, unknown> = {} +const mockToastNotify = vi.fn() + +const originalLocation = window.location +let assignedHref = '' + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), + useTheme: () => ({ theme: 'light' }), +})) + +vi.mock('@/app/components/base/icons/src/public/billing', () => ({ + Azure: () => <span data-testid="icon-azure" />, + GoogleCloud: () => <span data-testid="icon-gcloud" />, + AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />, + AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (args: unknown) => mockToastNotify(args) }, +})) + +vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({ + default: ({ plan }: { plan: string }) => ( + <div data-testid={`self-hosted-list-${plan}`}>Features</div> + ), +})) + +const setupAppContext = (overrides: Record<string, unknown> = {}) => { + mockAppCtx = { + isCurrentWorkspaceManager: true, + ...overrides, + } +} + +describe('Self-Hosted Plan Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupAppContext() + + // Mock window.location with minimal getter/setter (Location props are non-enumerable) + assignedHref = '' + Object.defineProperty(window, 'location', { + configurable: true, + value: { + get href() { return assignedHref }, + set href(value: string) { assignedHref = value }, + }, + }) + }) + + afterEach(() => { + // Restore original location + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }) + }) + + // ─── 1. Plan Rendering ────────────────────────────────────────────────── + describe('Plan rendering', () => { + it('should render community plan with name and description', () => { + render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />) + + expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.community\.description/i)).toBeInTheDocument() + }) + + it('should render premium plan with cloud provider icons', () => { + render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />) + + expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument() + expect(screen.getByTestId('icon-azure')).toBeInTheDocument() + expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument() + }) + + it('should render enterprise plan without cloud provider icons', () => { + render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />) + + expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument() + expect(screen.queryByTestId('icon-azure')).not.toBeInTheDocument() + }) + + it('should not show price tip for community (free) plan', () => { + render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />) + + expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument() + }) + + it('should show price tip for premium plan', () => { + render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />) + + expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument() + }) + + it('should render features list for each plan', () => { + const { unmount: unmount1 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />) + expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument() + unmount1() + + const { unmount: unmount2 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />) + expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument() + unmount2() + + render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />) + expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument() + }) + + it('should show AWS marketplace icon for premium plan button', () => { + render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />) + + expect(screen.getByTestId('icon-aws-light')).toBeInTheDocument() + }) + }) + + // ─── 2. Navigation Flow ───────────────────────────────────────────────── + describe('Navigation flow', () => { + it('should redirect to GitHub when clicking community plan button', async () => { + const user = userEvent.setup() + render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />) + + const button = screen.getByRole('button') + await user.click(button) + + expect(assignedHref).toBe(getStartedWithCommunityUrl) + }) + + it('should redirect to AWS Marketplace when clicking premium plan button', async () => { + const user = userEvent.setup() + render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />) + + const button = screen.getByRole('button') + await user.click(button) + + expect(assignedHref).toBe(getWithPremiumUrl) + }) + + it('should redirect to Typeform when clicking enterprise plan button', async () => { + const user = userEvent.setup() + render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />) + + const button = screen.getByRole('button') + await user.click(button) + + expect(assignedHref).toBe(contactSalesUrl) + }) + }) + + // ─── 3. Permission Check ──────────────────────────────────────────────── + describe('Permission check', () => { + it('should show error toast when non-manager clicks community button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + // Should NOT redirect + expect(assignedHref).toBe('') + }) + + it('should show error toast when non-manager clicks premium button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + expect(assignedHref).toBe('') + }) + + it('should show error toast when non-manager clicks enterprise button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + expect(assignedHref).toBe('') + }) + }) +}) diff --git a/web/app/components/billing/__tests__/config.spec.ts b/web/app/components/billing/__tests__/config.spec.ts new file mode 100644 index 0000000000..9e62c2162f --- /dev/null +++ b/web/app/components/billing/__tests__/config.spec.ts @@ -0,0 +1,141 @@ +import { ALL_PLANS, contactSalesUrl, contractSales, defaultPlan, getStartedWithCommunityUrl, getWithPremiumUrl, NUM_INFINITE, unAvailable } from '../config' +import { Priority } from '../type' + +describe('Billing Config', () => { + describe('Constants', () => { + it('should define NUM_INFINITE as -1', () => { + expect(NUM_INFINITE).toBe(-1) + }) + + it('should define contractSales string', () => { + expect(contractSales).toBe('contractSales') + }) + + it('should define unAvailable string', () => { + expect(unAvailable).toBe('unAvailable') + }) + + it('should define valid URL constants', () => { + expect(contactSalesUrl).toMatch(/^https:\/\//) + expect(getStartedWithCommunityUrl).toMatch(/^https:\/\//) + expect(getWithPremiumUrl).toMatch(/^https:\/\//) + }) + }) + + describe('ALL_PLANS', () => { + const requiredFields: (keyof typeof ALL_PLANS.sandbox)[] = [ + 'level', + 'price', + 'modelProviders', + 'teamWorkspace', + 'teamMembers', + 'buildApps', + 'documents', + 'vectorSpace', + 'documentsUploadQuota', + 'documentsRequestQuota', + 'apiRateLimit', + 'documentProcessingPriority', + 'messageRequest', + 'triggerEvents', + 'annotatedResponse', + 'logHistory', + ] + + it.each(['sandbox', 'professional', 'team'] as const)('should have all required fields for %s plan', (planKey) => { + const plan = ALL_PLANS[planKey] + for (const field of requiredFields) + expect(plan[field]).toBeDefined() + }) + + it('should have ascending plan levels: sandbox < professional < team', () => { + expect(ALL_PLANS.sandbox.level).toBeLessThan(ALL_PLANS.professional.level) + expect(ALL_PLANS.professional.level).toBeLessThan(ALL_PLANS.team.level) + }) + + it('should have ascending plan prices: sandbox < professional < team', () => { + expect(ALL_PLANS.sandbox.price).toBeLessThan(ALL_PLANS.professional.price) + expect(ALL_PLANS.professional.price).toBeLessThan(ALL_PLANS.team.price) + }) + + it('should have sandbox as the free plan', () => { + expect(ALL_PLANS.sandbox.price).toBe(0) + }) + + it('should have ascending team member limits', () => { + expect(ALL_PLANS.sandbox.teamMembers).toBeLessThan(ALL_PLANS.professional.teamMembers) + expect(ALL_PLANS.professional.teamMembers).toBeLessThan(ALL_PLANS.team.teamMembers) + }) + + it('should have ascending document processing priority', () => { + expect(ALL_PLANS.sandbox.documentProcessingPriority).toBe(Priority.standard) + expect(ALL_PLANS.professional.documentProcessingPriority).toBe(Priority.priority) + expect(ALL_PLANS.team.documentProcessingPriority).toBe(Priority.topPriority) + }) + + it('should have unlimited API rate limit for professional and team plans', () => { + expect(ALL_PLANS.sandbox.apiRateLimit).not.toBe(NUM_INFINITE) + expect(ALL_PLANS.professional.apiRateLimit).toBe(NUM_INFINITE) + expect(ALL_PLANS.team.apiRateLimit).toBe(NUM_INFINITE) + }) + + it('should have unlimited log history for professional and team plans', () => { + expect(ALL_PLANS.professional.logHistory).toBe(NUM_INFINITE) + expect(ALL_PLANS.team.logHistory).toBe(NUM_INFINITE) + }) + + it('should have unlimited trigger events only for team plan', () => { + expect(ALL_PLANS.sandbox.triggerEvents).not.toBe(NUM_INFINITE) + expect(ALL_PLANS.professional.triggerEvents).not.toBe(NUM_INFINITE) + expect(ALL_PLANS.team.triggerEvents).toBe(NUM_INFINITE) + }) + }) + + describe('defaultPlan', () => { + it('should default to sandbox plan type', () => { + expect(defaultPlan.type).toBe('sandbox') + }) + + it('should have usage object with all required fields', () => { + const { usage } = defaultPlan + expect(usage).toHaveProperty('documents') + expect(usage).toHaveProperty('vectorSpace') + expect(usage).toHaveProperty('buildApps') + expect(usage).toHaveProperty('teamMembers') + expect(usage).toHaveProperty('annotatedResponse') + expect(usage).toHaveProperty('documentsUploadQuota') + expect(usage).toHaveProperty('apiRateLimit') + expect(usage).toHaveProperty('triggerEvents') + }) + + it('should have total object with all required fields', () => { + const { total } = defaultPlan + expect(total).toHaveProperty('documents') + expect(total).toHaveProperty('vectorSpace') + expect(total).toHaveProperty('buildApps') + expect(total).toHaveProperty('teamMembers') + expect(total).toHaveProperty('annotatedResponse') + expect(total).toHaveProperty('documentsUploadQuota') + expect(total).toHaveProperty('apiRateLimit') + expect(total).toHaveProperty('triggerEvents') + }) + + it('should use sandbox plan API rate limit and trigger events in total', () => { + expect(defaultPlan.total.apiRateLimit).toBe(ALL_PLANS.sandbox.apiRateLimit) + expect(defaultPlan.total.triggerEvents).toBe(ALL_PLANS.sandbox.triggerEvents) + }) + + it('should have reset info with null values', () => { + expect(defaultPlan.reset.apiRateLimit).toBeNull() + expect(defaultPlan.reset.triggerEvents).toBeNull() + }) + + it('should have usage values not exceeding totals', () => { + expect(defaultPlan.usage.documents).toBeLessThanOrEqual(defaultPlan.total.documents) + expect(defaultPlan.usage.vectorSpace).toBeLessThanOrEqual(defaultPlan.total.vectorSpace) + expect(defaultPlan.usage.buildApps).toBeLessThanOrEqual(defaultPlan.total.buildApps) + expect(defaultPlan.usage.teamMembers).toBeLessThanOrEqual(defaultPlan.total.teamMembers) + expect(defaultPlan.usage.annotatedResponse).toBeLessThanOrEqual(defaultPlan.total.annotatedResponse) + }) + }) +}) diff --git a/web/app/components/billing/annotation-full/index.spec.tsx b/web/app/components/billing/annotation-full/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/billing/annotation-full/index.spec.tsx rename to web/app/components/billing/annotation-full/__tests__/index.spec.tsx index 2090605692..c98cb9fa5d 100644 --- a/web/app/components/billing/annotation-full/index.spec.tsx +++ b/web/app/components/billing/annotation-full/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' -import AnnotationFull from './index' +import AnnotationFull from '../index' -vi.mock('./usage', () => ({ +vi.mock('../usage', () => ({ default: (props: { className?: string }) => { return ( <div data-testid="usage-component" data-classname={props.className ?? ''}> @@ -11,7 +11,7 @@ vi.mock('./usage', () => ({ }, })) -vi.mock('../upgrade-btn', () => ({ +vi.mock('../../upgrade-btn', () => ({ default: (props: { loc?: string }) => { return ( <button type="button" data-testid="upgrade-btn"> @@ -29,27 +29,21 @@ describe('AnnotationFull', () => { // Rendering marketing copy with action button describe('Rendering', () => { it('should render tips when rendered', () => { - // Act render(<AnnotationFull />) - // Assert expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument() expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument() }) it('should render upgrade button when rendered', () => { - // Act render(<AnnotationFull />) - // Assert expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() }) it('should render Usage component when rendered', () => { - // Act render(<AnnotationFull />) - // Assert const usageComponent = screen.getByTestId('usage-component') expect(usageComponent).toBeInTheDocument() }) diff --git a/web/app/components/billing/annotation-full/modal.spec.tsx b/web/app/components/billing/annotation-full/__tests__/modal.spec.tsx similarity index 92% rename from web/app/components/billing/annotation-full/modal.spec.tsx rename to web/app/components/billing/annotation-full/__tests__/modal.spec.tsx index 90c440f1fb..7f39fa287c 100644 --- a/web/app/components/billing/annotation-full/modal.spec.tsx +++ b/web/app/components/billing/annotation-full/__tests__/modal.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' -import AnnotationFullModal from './modal' +import AnnotationFullModal from '../modal' -vi.mock('./usage', () => ({ +vi.mock('../usage', () => ({ default: (props: { className?: string }) => { return ( <div data-testid="usage-component" data-classname={props.className ?? ''}> @@ -12,7 +12,7 @@ vi.mock('./usage', () => ({ })) let mockUpgradeBtnProps: { loc?: string } | null = null -vi.mock('../upgrade-btn', () => ({ +vi.mock('../../upgrade-btn', () => ({ default: (props: { loc?: string }) => { mockUpgradeBtnProps = props return ( @@ -29,7 +29,7 @@ type ModalSnapshot = { className?: string } let mockModalProps: ModalSnapshot | null = null -vi.mock('../../base/modal', () => ({ +vi.mock('../../../base/modal', () => ({ default: ({ isShow, children, onClose, closable, className }: { isShow: boolean, children: React.ReactNode, onClose: () => void, closable?: boolean, className?: string }) => { mockModalProps = { isShow, @@ -61,10 +61,8 @@ describe('AnnotationFullModal', () => { // Rendering marketing copy inside modal describe('Rendering', () => { it('should display main info when visible', () => { - // Act render(<AnnotationFullModal show onHide={vi.fn()} />) - // Assert expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument() expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument() expect(screen.getByTestId('usage-component')).toHaveAttribute('data-classname', 'mt-4') @@ -81,10 +79,8 @@ describe('AnnotationFullModal', () => { // Controlling modal visibility describe('Visibility', () => { it('should not render content when hidden', () => { - // Act const { container } = render(<AnnotationFullModal show={false} onHide={vi.fn()} />) - // Assert expect(container).toBeEmptyDOMElement() expect(mockModalProps).toEqual(expect.objectContaining({ isShow: false })) }) @@ -93,14 +89,11 @@ describe('AnnotationFullModal', () => { // Handling close interactions describe('Close handling', () => { it('should trigger onHide when close control is clicked', () => { - // Arrange const onHide = vi.fn() - // Act render(<AnnotationFullModal show onHide={onHide} />) fireEvent.click(screen.getByTestId('mock-modal-close')) - // Assert expect(onHide).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/components/billing/annotation-full/usage.spec.tsx b/web/app/components/billing/annotation-full/__tests__/usage.spec.tsx similarity index 70% rename from web/app/components/billing/annotation-full/usage.spec.tsx rename to web/app/components/billing/annotation-full/__tests__/usage.spec.tsx index c5fd1a2b10..9ce8472b61 100644 --- a/web/app/components/billing/annotation-full/usage.spec.tsx +++ b/web/app/components/billing/annotation-full/__tests__/usage.spec.tsx @@ -1,11 +1,5 @@ import { render, screen } from '@testing-library/react' -import Usage from './usage' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import Usage from '../usage' const mockPlan = { usage: { @@ -23,33 +17,25 @@ vi.mock('@/context/provider-context', () => ({ })) describe('Usage', () => { - // Rendering: renders UsageInfo with correct props from context describe('Rendering', () => { it('should render usage info with data from provider context', () => { - // Arrange & Act render(<Usage />) - // Assert - expect(screen.getByText('annotatedResponse.quotaTitle')).toBeInTheDocument() + expect(screen.getByText('billing.annotatedResponse.quotaTitle')).toBeInTheDocument() }) it('should pass className to UsageInfo component', () => { - // Arrange const testClassName = 'mt-4' - // Act const { container } = render(<Usage className={testClassName} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass(testClassName) }) it('should display usage and total values from context', () => { - // Arrange & Act render(<Usage />) - // Assert expect(screen.getByText('50')).toBeInTheDocument() expect(screen.getByText('100')).toBeInTheDocument() }) diff --git a/web/app/components/billing/apps-full-in-dialog/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/billing/apps-full-in-dialog/index.spec.tsx rename to web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx index d006a3222d..9d435456b1 100644 --- a/web/app/components/billing/apps-full-in-dialog/index.spec.tsx +++ b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx @@ -8,7 +8,7 @@ import { Plan } from '@/app/components/billing/type' import { mailToSupport } from '@/app/components/header/utils/util' import { useAppContext } from '@/context/app-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' -import AppsFull from './index' +import AppsFull from '../index' vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), @@ -120,10 +120,8 @@ describe('AppsFull', () => { // Rendering behavior for non-team plans. describe('Rendering', () => { it('should render the sandbox messaging and upgrade button', () => { - // Act render(<AppsFull loc="billing_dialog" />) - // Assert expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument() expect(screen.getByText('billing.apps.fullTip1des')).toBeInTheDocument() expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() @@ -131,10 +129,8 @@ describe('AppsFull', () => { }) }) - // Prop-driven behavior for team plans and contact CTA. describe('Props', () => { it('should render team messaging and contact button for non-sandbox plans', () => { - // Arrange ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ plan: { ...baseProviderContextValue.plan, @@ -149,7 +145,6 @@ describe('AppsFull', () => { })) render(<AppsFull loc="billing_dialog" />) - // Assert expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument() expect(screen.getByText('billing.apps.fullTip2des')).toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() @@ -158,7 +153,6 @@ describe('AppsFull', () => { }) it('should render upgrade button for professional plans', () => { - // Arrange ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ plan: { ...baseProviderContextValue.plan, @@ -172,17 +166,14 @@ describe('AppsFull', () => { }, })) - // Act render(<AppsFull loc="billing_dialog" />) - // Assert expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument() expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() expect(screen.queryByText('billing.apps.contactUs')).not.toBeInTheDocument() }) it('should render contact button for enterprise plans', () => { - // Arrange ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ plan: { ...baseProviderContextValue.plan, @@ -196,10 +187,8 @@ describe('AppsFull', () => { }, })) - // Act render(<AppsFull loc="billing_dialog" />) - // Assert expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com') @@ -207,10 +196,8 @@ describe('AppsFull', () => { }) }) - // Edge cases for progress color thresholds. describe('Edge Cases', () => { it('should use the success color when usage is below 50%', () => { - // Arrange ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ plan: { ...baseProviderContextValue.plan, @@ -224,15 +211,12 @@ describe('AppsFull', () => { }, })) - // Act render(<AppsFull loc="billing_dialog" />) - // Assert expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid') }) it('should use the warning color when usage is between 50% and 80%', () => { - // Arrange ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ plan: { ...baseProviderContextValue.plan, @@ -246,15 +230,12 @@ describe('AppsFull', () => { }, })) - // Act render(<AppsFull loc="billing_dialog" />) - // Assert expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress') }) it('should use the error color when usage is 80% or higher', () => { - // Arrange ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ plan: { ...baseProviderContextValue.plan, @@ -268,10 +249,8 @@ describe('AppsFull', () => { }, })) - // Act render(<AppsFull loc="billing_dialog" />) - // Assert expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress') }) }) diff --git a/web/app/components/billing/billing-page/index.spec.tsx b/web/app/components/billing/billing-page/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/billing/billing-page/index.spec.tsx rename to web/app/components/billing/billing-page/__tests__/index.spec.tsx index f80c688d47..fe99fe3e4a 100644 --- a/web/app/components/billing/billing-page/index.spec.tsx +++ b/web/app/components/billing/billing-page/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import Billing from './index' +import Billing from '../index' let currentBillingUrl: string | null = 'https://billing' let fetching = false @@ -33,7 +33,7 @@ vi.mock('@/context/provider-context', () => ({ }), })) -vi.mock('../plan', () => ({ +vi.mock('../../plan', () => ({ default: ({ loc }: { loc: string }) => <div data-testid="plan-component" data-loc={loc} />, })) diff --git a/web/app/components/billing/header-billing-btn/index.spec.tsx b/web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx similarity index 65% rename from web/app/components/billing/header-billing-btn/index.spec.tsx rename to web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx index d2fc41c9c3..fa4825b1f1 100644 --- a/web/app/components/billing/header-billing-btn/index.spec.tsx +++ b/web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' -import { Plan } from '../type' -import HeaderBillingBtn from './index' +import { Plan } from '../../type' +import HeaderBillingBtn from '../index' type HeaderGlobal = typeof globalThis & { __mockProviderContext?: ReturnType<typeof vi.fn> @@ -26,7 +26,7 @@ vi.mock('@/context/provider-context', () => { } }) -vi.mock('../upgrade-btn', () => ({ +vi.mock('../../upgrade-btn', () => ({ default: () => <button data-testid="upgrade-btn" type="button">Upgrade</button>, })) @@ -70,6 +70,42 @@ describe('HeaderBillingBtn', () => { expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() }) + it('renders team badge for team plan with correct styling', () => { + ensureProviderContextMock().mockReturnValueOnce({ + plan: { type: Plan.team }, + enableBilling: true, + isFetchedPlan: true, + }) + + render(<HeaderBillingBtn />) + + const badge = screen.getByText('team').closest('div') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('bg-[#E0EAFF]') + }) + + it('renders nothing when plan is not fetched', () => { + ensureProviderContextMock().mockReturnValueOnce({ + plan: { type: Plan.professional }, + enableBilling: true, + isFetchedPlan: false, + }) + + const { container } = render(<HeaderBillingBtn />) + expect(container.firstChild).toBeNull() + }) + + it('renders sandbox upgrade btn with undefined onClick in display-only mode', () => { + ensureProviderContextMock().mockReturnValueOnce({ + plan: { type: Plan.sandbox }, + enableBilling: true, + isFetchedPlan: true, + }) + + render(<HeaderBillingBtn isDisplayOnly />) + expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() + }) + it('renders plan badge and forwards clicks when not display-only', () => { const onClick = vi.fn() diff --git a/web/app/components/billing/partner-stack/index.spec.tsx b/web/app/components/billing/partner-stack/__tests__/index.spec.tsx similarity index 57% rename from web/app/components/billing/partner-stack/index.spec.tsx rename to web/app/components/billing/partner-stack/__tests__/index.spec.tsx index d0dc9623c2..d8182c4103 100644 --- a/web/app/components/billing/partner-stack/index.spec.tsx +++ b/web/app/components/billing/partner-stack/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react' -import PartnerStack from './index' +import PartnerStack from '../index' let isCloudEdition = true @@ -12,7 +12,7 @@ vi.mock('@/config', () => ({ }, })) -vi.mock('./use-ps-info', () => ({ +vi.mock('../use-ps-info', () => ({ default: () => ({ saveOrUpdate, bind, @@ -40,4 +40,23 @@ describe('PartnerStack', () => { expect(saveOrUpdate).toHaveBeenCalledTimes(1) expect(bind).toHaveBeenCalledTimes(1) }) + + it('renders null (no visible DOM)', () => { + const { container } = render(<PartnerStack />) + + expect(container.innerHTML).toBe('') + }) + + it('does not call helpers again on rerender', () => { + const { rerender } = render(<PartnerStack />) + + expect(saveOrUpdate).toHaveBeenCalledTimes(1) + expect(bind).toHaveBeenCalledTimes(1) + + rerender(<PartnerStack />) + + // useEffect with [] should not run again on rerender + expect(saveOrUpdate).toHaveBeenCalledTimes(1) + expect(bind).toHaveBeenCalledTimes(1) + }) }) diff --git a/web/app/components/billing/partner-stack/use-ps-info.spec.tsx b/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx similarity index 60% rename from web/app/components/billing/partner-stack/use-ps-info.spec.tsx rename to web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx index 03ee03fc81..ec79d18d29 100644 --- a/web/app/components/billing/partner-stack/use-ps-info.spec.tsx +++ b/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx @@ -1,6 +1,6 @@ import { act, renderHook } from '@testing-library/react' import { PARTNER_STACK_CONFIG } from '@/config' -import usePSInfo from './use-ps-info' +import usePSInfo from '../use-ps-info' let searchParamsValues: Record<string, string | null> = {} const setSearchParams = (values: Record<string, string | null>) => { @@ -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() + }) }) diff --git a/web/app/components/billing/plan-upgrade-modal/index.spec.tsx b/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx similarity index 93% rename from web/app/components/billing/plan-upgrade-modal/index.spec.tsx rename to web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx index 5dc7515344..b28ffffa53 100644 --- a/web/app/components/billing/plan-upgrade-modal/index.spec.tsx +++ b/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import PlanUpgradeModal from './index' +import PlanUpgradeModal from '../index' const mockSetShowPricingModal = vi.fn() @@ -39,13 +39,11 @@ describe('PlanUpgradeModal', () => { // Rendering and props-driven content it('should render modal with provided content when visible', () => { - // Arrange const extraInfoText = 'Additional upgrade details' renderComponent({ extraInfo: <div>{extraInfoText}</div>, }) - // Assert expect(screen.getByText(baseProps.title)).toBeInTheDocument() expect(screen.getByText(baseProps.description)).toBeInTheDocument() expect(screen.getByText(extraInfoText)).toBeInTheDocument() @@ -55,40 +53,32 @@ describe('PlanUpgradeModal', () => { // Guard against rendering when modal is hidden it('should not render content when show is false', () => { - // Act renderComponent({ show: false }) - // Assert expect(screen.queryByText(baseProps.title)).not.toBeInTheDocument() expect(screen.queryByText(baseProps.description)).not.toBeInTheDocument() }) // User closes the modal from dismiss button it('should call onClose when dismiss button is clicked', async () => { - // Arrange const user = userEvent.setup() const onClose = vi.fn() renderComponent({ onClose }) - // Act await user.click(screen.getByText('billing.triggerLimitModal.dismiss')) - // Assert expect(onClose).toHaveBeenCalledTimes(1) }) // Upgrade path uses provided callback over pricing modal it('should call onUpgrade and onClose when upgrade button is clicked with onUpgrade provided', async () => { - // Arrange const user = userEvent.setup() const onClose = vi.fn() const onUpgrade = vi.fn() renderComponent({ onClose, onUpgrade }) - // Act await user.click(screen.getByText('billing.triggerLimitModal.upgrade')) - // Assert expect(onClose).toHaveBeenCalledTimes(1) expect(onUpgrade).toHaveBeenCalledTimes(1) expect(mockSetShowPricingModal).not.toHaveBeenCalled() @@ -96,15 +86,12 @@ describe('PlanUpgradeModal', () => { // Fallback upgrade path opens pricing modal when no onUpgrade is supplied it('should open pricing modal when upgrade button is clicked without onUpgrade', async () => { - // Arrange const user = userEvent.setup() const onClose = vi.fn() renderComponent({ onClose, onUpgrade: undefined }) - // Act await user.click(screen.getByText('billing.triggerLimitModal.upgrade')) - // Assert expect(onClose).toHaveBeenCalledTimes(1) expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/billing/plan/index.spec.tsx b/web/app/components/billing/plan/__tests__/index.spec.tsx similarity index 69% rename from web/app/components/billing/plan/index.spec.tsx rename to web/app/components/billing/plan/__tests__/index.spec.tsx index db22b47db4..79597b4b22 100644 --- a/web/app/components/billing/plan/index.spec.tsx +++ b/web/app/components/billing/plan/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' -import { Plan } from '../type' -import PlanComp from './index' +import { Plan, SelfHostedPlan } from '../../type' +import PlanComp from '../index' let currentPath = '/billing' @@ -14,8 +14,7 @@ vi.mock('next/navigation', () => ({ const setShowAccountSettingModalMock = vi.fn() vi.mock('@/context/modal-context', () => ({ - // eslint-disable-next-line ts/no-explicit-any - useModalContextSelector: (selector: any) => selector({ + useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof setShowAccountSettingModalMock }) => unknown) => selector({ setShowAccountSettingModal: setShowAccountSettingModalMock, }), })) @@ -47,11 +46,10 @@ const verifyStateModalMock = vi.fn(props => ( </div> )) vi.mock('@/app/education-apply/verify-state-modal', () => ({ - // eslint-disable-next-line ts/no-explicit-any - default: (props: any) => verifyStateModalMock(props), + default: (props: { isShow: boolean, title?: string, content?: string, email?: string, showLink?: boolean, onConfirm?: () => void, onCancel?: () => void }) => verifyStateModalMock(props), })) -vi.mock('../upgrade-btn', () => ({ +vi.mock('../../upgrade-btn', () => ({ default: () => <button data-testid="plan-upgrade-btn" type="button">Upgrade</button>, })) @@ -172,6 +170,66 @@ describe('PlanComp', () => { expect(screen.getByText('education.toVerified')).toBeInTheDocument() }) + it('renders enterprise plan without upgrade button', () => { + providerContextMock.mockReturnValue({ + plan: { ...planMock, type: SelfHostedPlan.enterprise }, + enableEducationPlan: false, + allowRefreshEducationVerify: false, + isEducationAccount: false, + }) + render(<PlanComp loc="billing-page" />) + + expect(screen.getByText('billing.plans.enterprise.name')).toBeInTheDocument() + expect(screen.queryByTestId('plan-upgrade-btn')).not.toBeInTheDocument() + }) + + it('shows apiRateLimit reset info for sandbox plan', () => { + providerContextMock.mockReturnValue({ + plan: { + ...planMock, + type: Plan.sandbox, + total: { ...planMock.total, apiRateLimit: 5000 }, + reset: { ...planMock.reset, apiRateLimit: null }, + }, + enableEducationPlan: false, + allowRefreshEducationVerify: false, + isEducationAccount: false, + }) + render(<PlanComp loc="billing-page" />) + + // Sandbox plan with finite apiRateLimit and null reset uses getDaysUntilEndOfMonth() + expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument() + }) + + it('shows apiRateLimit reset info when reset is a number', () => { + providerContextMock.mockReturnValue({ + plan: { + ...planMock, + type: Plan.professional, + total: { ...planMock.total, apiRateLimit: 5000 }, + reset: { ...planMock.reset, apiRateLimit: 3 }, + }, + enableEducationPlan: false, + allowRefreshEducationVerify: false, + isEducationAccount: false, + }) + render(<PlanComp loc="billing-page" />) + + expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument() + }) + + it('does not show education verify when enableEducationPlan is false', () => { + providerContextMock.mockReturnValue({ + plan: planMock, + enableEducationPlan: false, + allowRefreshEducationVerify: false, + isEducationAccount: false, + }) + render(<PlanComp loc="billing-page" />) + + expect(screen.queryByText('education.toVerified')).not.toBeInTheDocument() + }) + it('handles modal onConfirm and onCancel callbacks', async () => { mutateAsyncMock.mockRejectedValueOnce(new Error('boom')) render(<PlanComp loc="billing-page" />) diff --git a/web/app/components/billing/plan/assets/enterprise.spec.tsx b/web/app/components/billing/plan/assets/__tests__/enterprise.spec.tsx similarity index 99% rename from web/app/components/billing/plan/assets/enterprise.spec.tsx rename to web/app/components/billing/plan/assets/__tests__/enterprise.spec.tsx index 8d5dd8347a..08458035ff 100644 --- a/web/app/components/billing/plan/assets/enterprise.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/enterprise.spec.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react' -import Enterprise from './enterprise' +import Enterprise from '../enterprise' describe('Enterprise Icon Component', () => { describe('Rendering', () => { diff --git a/web/app/components/billing/plan/assets/index.spec.tsx b/web/app/components/billing/plan/assets/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/billing/plan/assets/index.spec.tsx rename to web/app/components/billing/plan/assets/__tests__/index.spec.tsx index 4d44a6e6d1..9fde4a4094 100644 --- a/web/app/components/billing/plan/assets/index.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/index.spec.tsx @@ -1,11 +1,11 @@ import { render } from '@testing-library/react' -import EnterpriseDirect from './enterprise' +import EnterpriseDirect from '../enterprise' -import { Enterprise, Professional, Sandbox, Team } from './index' -import ProfessionalDirect from './professional' +import { Enterprise, Professional, Sandbox, Team } from '../index' +import ProfessionalDirect from '../professional' // Import real components for comparison -import SandboxDirect from './sandbox' -import TeamDirect from './team' +import SandboxDirect from '../sandbox' +import TeamDirect from '../team' describe('Billing Plan Assets - Integration Tests', () => { describe('Exports', () => { diff --git a/web/app/components/billing/plan/assets/professional.spec.tsx b/web/app/components/billing/plan/assets/__tests__/professional.spec.tsx similarity index 99% rename from web/app/components/billing/plan/assets/professional.spec.tsx rename to web/app/components/billing/plan/assets/__tests__/professional.spec.tsx index f8cccac40f..dcd63711fa 100644 --- a/web/app/components/billing/plan/assets/professional.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/professional.spec.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react' -import Professional from './professional' +import Professional from '../professional' describe('Professional Icon Component', () => { describe('Rendering', () => { diff --git a/web/app/components/billing/plan/assets/sandbox.spec.tsx b/web/app/components/billing/plan/assets/__tests__/sandbox.spec.tsx similarity index 99% rename from web/app/components/billing/plan/assets/sandbox.spec.tsx rename to web/app/components/billing/plan/assets/__tests__/sandbox.spec.tsx index 024213cf5a..7d256b4fc1 100644 --- a/web/app/components/billing/plan/assets/sandbox.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/sandbox.spec.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' import * as React from 'react' -import Sandbox from './sandbox' +import Sandbox from '../sandbox' describe('Sandbox Icon Component', () => { describe('Rendering', () => { diff --git a/web/app/components/billing/plan/assets/team.spec.tsx b/web/app/components/billing/plan/assets/__tests__/team.spec.tsx similarity index 99% rename from web/app/components/billing/plan/assets/team.spec.tsx rename to web/app/components/billing/plan/assets/__tests__/team.spec.tsx index d4d1e713d8..ffd5571a4d 100644 --- a/web/app/components/billing/plan/assets/team.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/team.spec.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react' -import Team from './team' +import Team from '../team' describe('Team Icon Component', () => { describe('Rendering', () => { diff --git a/web/app/components/billing/pricing/footer.spec.tsx b/web/app/components/billing/pricing/__tests__/footer.spec.tsx similarity index 87% rename from web/app/components/billing/pricing/footer.spec.tsx rename to web/app/components/billing/pricing/__tests__/footer.spec.tsx index 85bd72c247..7ef78180de 100644 --- a/web/app/components/billing/pricing/footer.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/footer.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import { CategoryEnum } from '.' -import Footer from './footer' +import { CategoryEnum } from '..' +import Footer from '../footer' vi.mock('next/link', () => ({ default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( @@ -16,13 +16,10 @@ describe('Footer', () => { vi.clearAllMocks() }) - // Rendering behavior describe('Rendering', () => { it('should render tax tips and comparison link when in cloud category', () => { - // Arrange render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.CLOUD} />) - // Assert expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument() expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features') @@ -30,25 +27,19 @@ describe('Footer', () => { }) }) - // Prop-driven behavior describe('Props', () => { it('should hide tax tips when category is self-hosted', () => { - // Arrange render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.SELF} />) - // Assert expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument() expect(screen.queryByText('billing.plansCommon.taxTipSecond')).not.toBeInTheDocument() }) }) - // Edge case rendering behavior describe('Edge Cases', () => { it('should render link even when pricing URL is empty', () => { - // Arrange render(<Footer pricingPageURL="" currentCategory={CategoryEnum.CLOUD} />) - // Assert expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', '') }) }) diff --git a/web/app/components/billing/pricing/header.spec.tsx b/web/app/components/billing/pricing/__tests__/header.spec.tsx similarity index 55% rename from web/app/components/billing/pricing/header.spec.tsx rename to web/app/components/billing/pricing/__tests__/header.spec.tsx index 6c32af23b2..e1cb18ca3f 100644 --- a/web/app/components/billing/pricing/header.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/header.spec.tsx @@ -1,74 +1,39 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import Header from './header' - -let mockTranslations: Record<string, string> = {} - -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal<typeof import('react-i18next')>() - return { - ...actual, - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - if (mockTranslations[key]) - return mockTranslations[key] - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), - } -}) +import Header from '../header' describe('Header', () => { beforeEach(() => { vi.clearAllMocks() - mockTranslations = {} }) - // Rendering behavior describe('Rendering', () => { it('should render title and description translations', () => { - // Arrange const handleClose = vi.fn() - // Act render(<Header onClose={handleClose} />) - // Assert expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument() expect(screen.getByRole('button')).toBeInTheDocument() }) }) - // Prop-driven behavior describe('Props', () => { it('should invoke onClose when close button is clicked', () => { - // Arrange const handleClose = vi.fn() render(<Header onClose={handleClose} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(handleClose).toHaveBeenCalledTimes(1) }) }) - // Edge case rendering behavior describe('Edge Cases', () => { - it('should render structure when translations are empty strings', () => { - // Arrange - mockTranslations = { - 'billing.plansCommon.title.plans': '', - 'billing.plansCommon.title.description': '', - } - - // Act + it('should render structural elements with translation keys', () => { const { container } = render(<Header onClose={vi.fn()} />) - // Assert expect(container.querySelector('span')).toBeInTheDocument() expect(container.querySelector('p')).toBeInTheDocument() expect(screen.getByRole('button')).toBeInTheDocument() diff --git a/web/app/components/billing/pricing/index.spec.tsx b/web/app/components/billing/pricing/__tests__/index.spec.tsx similarity index 66% rename from web/app/components/billing/pricing/index.spec.tsx rename to web/app/components/billing/pricing/__tests__/index.spec.tsx index 89f39bd75c..54813ae0d7 100644 --- a/web/app/components/billing/pricing/index.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/index.spec.tsx @@ -1,17 +1,24 @@ import type { Mock } from 'vitest' -import type { UsagePlanInfo } from '../type' +import type { UsagePlanInfo } from '../../type' import { fireEvent, render, screen } from '@testing-library/react' -import { useKeyPress } from 'ahooks' import * as React from 'react' import { useAppContext } from '@/context/app-context' import { useGetPricingPageLanguage } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' -import { Plan } from '../type' -import Pricing from './index' +import { Plan } from '../../type' +import Pricing from '../index' -let mockTranslations: Record<string, string> = {} let mockLanguage: string | null = 'en' +vi.mock('../plans/self-hosted-plan-item/list', () => ({ + default: ({ plan }: { plan: string }) => ( + <div data-testid={`list-${plan}`}> + List for + {plan} + </div> + ), +})) + vi.mock('next/link', () => ({ default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( <a href={href} className={className} target={target} data-testid="pricing-link"> @@ -20,10 +27,6 @@ vi.mock('next/link', () => ({ ), })) -vi.mock('ahooks', () => ({ - useKeyPress: vi.fn(), -})) - vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) @@ -36,24 +39,6 @@ vi.mock('@/context/i18n', () => ({ useGetPricingPageLanguage: vi.fn(), })) -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal<typeof import('react-i18next')>() - return { - ...actual, - useTranslation: () => ({ - t: (key: string, options?: { returnObjects?: boolean, ns?: string }) => { - if (options?.returnObjects) - return mockTranslations[key] ?? [] - if (mockTranslations[key]) - return mockTranslations[key] - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), - Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>, - } -}) - const buildUsage = (): UsagePlanInfo => ({ buildApps: 0, teamMembers: 0, @@ -67,7 +52,6 @@ const buildUsage = (): UsagePlanInfo => ({ describe('Pricing', () => { beforeEach(() => { vi.clearAllMocks() - mockTranslations = {} mockLanguage = 'en' ;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true }) ;(useProviderContext as Mock).mockReturnValue({ @@ -80,42 +64,33 @@ describe('Pricing', () => { ;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage) }) - // Rendering behavior describe('Rendering', () => { it('should render pricing header and localized footer link', () => { - // Arrange render(<Pricing onCancel={vi.fn()} />) - // Assert expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument() expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features') }) }) - // Prop-driven behavior describe('Props', () => { - it('should register esc key handler and allow switching categories', () => { - // Arrange + it('should allow switching categories and handle esc key', () => { const handleCancel = vi.fn() render(<Pricing onCancel={handleCancel} />) - // Act fireEvent.click(screen.getByText('billing.plansCommon.self')) - - // Assert - expect(useKeyPress).toHaveBeenCalledWith(['esc'], handleCancel) expect(screen.queryByRole('switch')).not.toBeInTheDocument() + + fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 }) + expect(handleCancel).toHaveBeenCalled() }) }) - // Edge case rendering behavior describe('Edge Cases', () => { it('should fall back to default pricing URL when language is empty', () => { - // Arrange mockLanguage = '' render(<Pricing onCancel={vi.fn()} />) - // Assert expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features') }) }) diff --git a/web/app/components/billing/pricing/assets/__tests__/components.spec.tsx b/web/app/components/billing/pricing/assets/__tests__/components.spec.tsx new file mode 100644 index 0000000000..b69bbc5eb1 --- /dev/null +++ b/web/app/components/billing/pricing/assets/__tests__/components.spec.tsx @@ -0,0 +1,81 @@ +import { render } from '@testing-library/react' +import { + Cloud, + Community, + Enterprise, + EnterpriseNoise, + NoiseBottom, + NoiseTop, + Premium, + PremiumNoise, + Professional, + Sandbox, + SelfHosted, + Team, +} from '../index' + +// Static SVG components (no props) +describe('Static Pricing Asset Components', () => { + const staticComponents = [ + { name: 'Community', Component: Community }, + { name: 'Enterprise', Component: Enterprise }, + { name: 'EnterpriseNoise', Component: EnterpriseNoise }, + { name: 'NoiseBottom', Component: NoiseBottom }, + { name: 'NoiseTop', Component: NoiseTop }, + { name: 'Premium', Component: Premium }, + { name: 'PremiumNoise', Component: PremiumNoise }, + { name: 'Professional', Component: Professional }, + { name: 'Sandbox', Component: Sandbox }, + { name: 'Team', Component: Team }, + ] + + it.each(staticComponents)('$name should render an SVG element', ({ Component }) => { + const { container } = render(<Component />) + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it.each(staticComponents)('$name should render without errors on rerender', ({ Component }) => { + const { container, rerender } = render(<Component />) + rerender(<Component />) + expect(container.querySelector('svg')).toBeInTheDocument() + }) +}) + +// Interactive SVG components with isActive prop +describe('Cloud', () => { + it('should render an SVG element', () => { + const { container } = render(<Cloud isActive={false} />) + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should use primary color when inactive', () => { + const { container } = render(<Cloud isActive={false} />) + const rects = container.querySelectorAll('rect[fill="var(--color-text-primary)"]') + expect(rects.length).toBeGreaterThan(0) + }) + + it('should use accent color when active', () => { + const { container } = render(<Cloud isActive={true} />) + const rects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-accessible)"]') + expect(rects.length).toBeGreaterThan(0) + }) +}) + +describe('SelfHosted', () => { + it('should render an SVG element', () => { + const { container } = render(<SelfHosted isActive={false} />) + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should use primary color when inactive', () => { + const { container } = render(<SelfHosted isActive={false} />) + const rects = container.querySelectorAll('rect[fill="var(--color-text-primary)"]') + expect(rects.length).toBeGreaterThan(0) + }) + + it('should use accent color when active', () => { + const { container } = render(<SelfHosted isActive={true} />) + const rects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-accessible)"]') + expect(rects.length).toBeGreaterThan(0) + }) +}) diff --git a/web/app/components/billing/pricing/assets/index.spec.tsx b/web/app/components/billing/pricing/assets/__tests__/index.spec.tsx similarity index 91% rename from web/app/components/billing/pricing/assets/index.spec.tsx rename to web/app/components/billing/pricing/assets/__tests__/index.spec.tsx index cc56c57593..086c31b8d1 100644 --- a/web/app/components/billing/pricing/assets/index.spec.tsx +++ b/web/app/components/billing/pricing/assets/__tests__/index.spec.tsx @@ -12,13 +12,11 @@ import { Sandbox, SelfHosted, Team, -} from './index' +} from '../index' describe('Pricing Assets', () => { - // Rendering: each asset should render an svg. describe('Rendering', () => { it('should render static assets without crashing', () => { - // Arrange const assets = [ <Community key="community" />, <Enterprise key="enterprise" />, @@ -44,37 +42,29 @@ describe('Pricing Assets', () => { // Props: active state should change fill color for selectable assets. describe('Props', () => { it('should render active state for Cloud', () => { - // Arrange const { container } = render(<Cloud isActive />) - // Assert const rects = Array.from(container.querySelectorAll('rect')) expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true) }) it('should render inactive state for Cloud', () => { - // Arrange const { container } = render(<Cloud isActive={false} />) - // Assert const rects = Array.from(container.querySelectorAll('rect')) expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true) }) it('should render active state for SelfHosted', () => { - // Arrange const { container } = render(<SelfHosted isActive />) - // Assert const rects = Array.from(container.querySelectorAll('rect')) expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true) }) it('should render inactive state for SelfHosted', () => { - // Arrange const { container } = render(<SelfHosted isActive={false} />) - // Assert const rects = Array.from(container.querySelectorAll('rect')) expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true) }) diff --git a/web/app/components/billing/pricing/plan-switcher/index.spec.tsx b/web/app/components/billing/pricing/plan-switcher/__tests__/index.spec.tsx similarity index 65% rename from web/app/components/billing/pricing/plan-switcher/index.spec.tsx rename to web/app/components/billing/pricing/plan-switcher/__tests__/index.spec.tsx index 6a3af8a589..51e074e305 100644 --- a/web/app/components/billing/pricing/plan-switcher/index.spec.tsx +++ b/web/app/components/billing/pricing/plan-switcher/__tests__/index.spec.tsx @@ -1,36 +1,16 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import { CategoryEnum } from '../index' -import PlanSwitcher from './index' -import { PlanRange } from './plan-range-switcher' - -let mockTranslations: Record<string, string> = {} - -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal<typeof import('react-i18next')>() - return { - ...actual, - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - if (key in mockTranslations) - return mockTranslations[key] - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), - } -}) +import { CategoryEnum } from '../../index' +import PlanSwitcher from '../index' +import { PlanRange } from '../plan-range-switcher' describe('PlanSwitcher', () => { beforeEach(() => { vi.clearAllMocks() - mockTranslations = {} }) - // Rendering behavior describe('Rendering', () => { it('should render category tabs and plan range switcher for cloud', () => { - // Arrange render( <PlanSwitcher currentCategory={CategoryEnum.CLOUD} @@ -40,17 +20,14 @@ describe('PlanSwitcher', () => { />, ) - // Assert expect(screen.getByText('billing.plansCommon.cloud')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.self')).toBeInTheDocument() expect(screen.getByRole('switch')).toBeInTheDocument() }) }) - // Prop-driven behavior describe('Props', () => { it('should call onChangeCategory when selecting a tab', () => { - // Arrange const handleChangeCategory = vi.fn() render( <PlanSwitcher @@ -61,16 +38,13 @@ describe('PlanSwitcher', () => { />, ) - // Act fireEvent.click(screen.getByText('billing.plansCommon.self')) - // Assert expect(handleChangeCategory).toHaveBeenCalledTimes(1) expect(handleChangeCategory).toHaveBeenCalledWith(CategoryEnum.SELF) }) it('should hide plan range switcher when category is self-hosted', () => { - // Arrange render( <PlanSwitcher currentCategory={CategoryEnum.SELF} @@ -80,21 +54,12 @@ describe('PlanSwitcher', () => { />, ) - // Assert expect(screen.queryByRole('switch')).not.toBeInTheDocument() }) }) - // Edge case rendering behavior describe('Edge Cases', () => { - it('should render tabs when translation strings are empty', () => { - // Arrange - mockTranslations = { - 'plansCommon.cloud': '', - 'plansCommon.self': '', - } - - // Act + it('should render tabs with translation keys', () => { const { container } = render( <PlanSwitcher currentCategory={CategoryEnum.SELF} @@ -104,11 +69,10 @@ describe('PlanSwitcher', () => { />, ) - // Assert const labels = container.querySelectorAll('span') expect(labels).toHaveLength(2) - expect(labels[0]?.textContent).toBe('') - expect(labels[1]?.textContent).toBe('') + expect(labels[0]?.textContent).toBe('billing.plansCommon.cloud') + expect(labels[1]?.textContent).toBe('billing.plansCommon.self') }) }) }) diff --git a/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.spec.tsx b/web/app/components/billing/pricing/plan-switcher/__tests__/plan-range-switcher.spec.tsx similarity index 50% rename from web/app/components/billing/pricing/plan-switcher/plan-range-switcher.spec.tsx rename to web/app/components/billing/pricing/plan-switcher/__tests__/plan-range-switcher.spec.tsx index 50634bec5e..104c310a53 100644 --- a/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.spec.tsx +++ b/web/app/components/billing/pricing/plan-switcher/__tests__/plan-range-switcher.spec.tsx @@ -1,86 +1,50 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import PlanRangeSwitcher, { PlanRange } from './plan-range-switcher' - -let mockTranslations: Record<string, string> = {} - -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal<typeof import('react-i18next')>() - return { - ...actual, - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - if (mockTranslations[key]) - return mockTranslations[key] - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), - } -}) +import PlanRangeSwitcher, { PlanRange } from '../plan-range-switcher' describe('PlanRangeSwitcher', () => { beforeEach(() => { vi.clearAllMocks() - mockTranslations = {} }) - // Rendering behavior describe('Rendering', () => { it('should render the annual billing label', () => { - // Arrange render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />) - // Assert - expect(screen.getByText('billing.plansCommon.annualBilling')).toBeInTheDocument() + expect(screen.getByText(/billing\.plansCommon\.annualBilling/)).toBeInTheDocument() expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') }) }) - // Prop-driven behavior describe('Props', () => { it('should switch to yearly when toggled from monthly', () => { - // Arrange const handleChange = vi.fn() render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={handleChange} />) - // Act fireEvent.click(screen.getByRole('switch')) - // Assert expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith(PlanRange.yearly) }) it('should switch to monthly when toggled from yearly', () => { - // Arrange const handleChange = vi.fn() render(<PlanRangeSwitcher value={PlanRange.yearly} onChange={handleChange} />) - // Act fireEvent.click(screen.getByRole('switch')) - // Assert expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith(PlanRange.monthly) }) }) - // Edge case rendering behavior describe('Edge Cases', () => { - it('should render when the translation string is empty', () => { - // Arrange - mockTranslations = { - 'billing.plansCommon.annualBilling': '', - } + it('should render label with translation key and params', () => { + render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />) - // Act - const { container } = render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />) - - // Assert - const label = container.querySelector('span') + const label = screen.getByText(/billing\.plansCommon\.annualBilling/) expect(label).toBeInTheDocument() - expect(label?.textContent).toBe('') + expect(label.textContent).toContain('percent') }) }) }) diff --git a/web/app/components/billing/pricing/plan-switcher/tab.spec.tsx b/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx similarity index 88% rename from web/app/components/billing/pricing/plan-switcher/tab.spec.tsx rename to web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx index 5c335e0dd1..abb18b5126 100644 --- a/web/app/components/billing/pricing/plan-switcher/tab.spec.tsx +++ b/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import Tab from './tab' +import Tab from '../tab' const Icon = ({ isActive }: { isActive: boolean }) => ( <svg data-testid="tab-icon" data-active={isActive ? 'true' : 'false'} /> @@ -11,10 +11,8 @@ describe('PlanSwitcherTab', () => { vi.clearAllMocks() }) - // Rendering behavior describe('Rendering', () => { it('should render label and icon', () => { - // Arrange render( <Tab Icon={Icon} @@ -25,16 +23,13 @@ describe('PlanSwitcherTab', () => { />, ) - // Assert expect(screen.getByText('Cloud')).toBeInTheDocument() expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'false') }) }) - // Prop-driven behavior describe('Props', () => { it('should call onClick with the provided value', () => { - // Arrange const handleClick = vi.fn() render( <Tab @@ -46,16 +41,13 @@ describe('PlanSwitcherTab', () => { />, ) - // Act fireEvent.click(screen.getByText('Self')) - // Assert expect(handleClick).toHaveBeenCalledTimes(1) expect(handleClick).toHaveBeenCalledWith('self') }) it('should apply active text class when isActive is true', () => { - // Arrange render( <Tab Icon={Icon} @@ -66,16 +58,13 @@ describe('PlanSwitcherTab', () => { />, ) - // Assert expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible') expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true') }) }) - // Edge case rendering behavior describe('Edge Cases', () => { it('should render when label is empty', () => { - // Arrange const { container } = render( <Tab Icon={Icon} @@ -86,7 +75,6 @@ describe('PlanSwitcherTab', () => { />, ) - // Assert const label = container.querySelector('span') expect(label).toBeInTheDocument() expect(label?.textContent).toBe('') diff --git a/web/app/components/billing/pricing/plans/index.spec.tsx b/web/app/components/billing/pricing/plans/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/billing/pricing/plans/index.spec.tsx rename to web/app/components/billing/pricing/plans/__tests__/index.spec.tsx index b89d4f87b3..0a407de39a 100644 --- a/web/app/components/billing/pricing/plans/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/__tests__/index.spec.tsx @@ -1,14 +1,14 @@ import type { Mock } from 'vitest' -import type { UsagePlanInfo } from '../../type' +import type { UsagePlanInfo } from '../../../type' import { render, screen } from '@testing-library/react' import * as React from 'react' -import { Plan } from '../../type' -import { PlanRange } from '../plan-switcher/plan-range-switcher' -import cloudPlanItem from './cloud-plan-item' -import Plans from './index' -import selfHostedPlanItem from './self-hosted-plan-item' +import { Plan } from '../../../type' +import { PlanRange } from '../../plan-switcher/plan-range-switcher' +import cloudPlanItem from '../cloud-plan-item' +import Plans from '../index' +import selfHostedPlanItem from '../self-hosted-plan-item' -vi.mock('./cloud-plan-item', () => ({ +vi.mock('../cloud-plan-item', () => ({ default: vi.fn(props => ( <div data-testid={`cloud-plan-${props.plan}`} data-current-plan={props.currentPlan}> Cloud @@ -18,7 +18,7 @@ vi.mock('./cloud-plan-item', () => ({ )), })) -vi.mock('./self-hosted-plan-item', () => ({ +vi.mock('../self-hosted-plan-item', () => ({ default: vi.fn(props => ( <div data-testid={`self-plan-${props.plan}`}> Self diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/button.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/button.spec.tsx similarity index 89% rename from web/app/components/billing/pricing/plans/cloud-plan-item/button.spec.tsx rename to web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/button.spec.tsx index d57f1c022d..6b0579d789 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/button.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/button.spec.tsx @@ -1,13 +1,12 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import { Plan } from '../../../type' -import Button from './button' +import { Plan } from '../../../../type' +import Button from '../button' describe('CloudPlanButton', () => { describe('Disabled state', () => { it('should disable button and hide arrow when plan is not available', () => { const handleGetPayUrl = vi.fn() - // Arrange render( <Button plan={Plan.team} @@ -18,7 +17,6 @@ describe('CloudPlanButton', () => { ) const button = screen.getByRole('button', { name: /Get started/i }) - // Assert expect(button).toBeDisabled() expect(button.className).toContain('cursor-not-allowed') expect(handleGetPayUrl).not.toHaveBeenCalled() @@ -28,7 +26,6 @@ describe('CloudPlanButton', () => { describe('Enabled state', () => { it('should invoke handler and render arrow when plan is available', () => { const handleGetPayUrl = vi.fn() - // Arrange render( <Button plan={Plan.sandbox} @@ -39,10 +36,8 @@ describe('CloudPlanButton', () => { ) const button = screen.getByRole('button', { name: /Start now/i }) - // Act fireEvent.click(button) - // Assert expect(handleGetPayUrl).toHaveBeenCalledTimes(1) expect(button).not.toBeDisabled() }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx similarity index 52% rename from web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx rename to web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx index a7945a7203..1c7283abeb 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx @@ -5,13 +5,13 @@ import { useAppContext } from '@/context/app-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { fetchSubscriptionUrls } from '@/service/billing' import { consoleClient } from '@/service/client' -import Toast from '../../../../base/toast' -import { ALL_PLANS } from '../../../config' -import { Plan } from '../../../type' -import { PlanRange } from '../../plan-switcher/plan-range-switcher' -import CloudPlanItem from './index' +import Toast from '../../../../../base/toast' +import { ALL_PLANS } from '../../../../config' +import { Plan } from '../../../../type' +import { PlanRange } from '../../../plan-switcher/plan-range-switcher' +import CloudPlanItem from '../index' -vi.mock('../../../../base/toast', () => ({ +vi.mock('../../../../../base/toast', () => ({ default: { notify: vi.fn(), }, @@ -37,7 +37,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({ useAsyncWindowOpen: vi.fn(), })) -vi.mock('../../assets', () => ({ +vi.mock('../../../assets', () => ({ Sandbox: () => <div>Sandbox Icon</div>, Professional: () => <div>Professional Icon</div>, Team: () => <div>Team Icon</div>, @@ -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', () => { @@ -117,6 +117,32 @@ describe('CloudPlanItem', () => { expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/)).toBeInTheDocument() }) + it('should show "most popular" badge for professional plan', () => { + render( + <CloudPlanItem + plan={Plan.professional} + currentPlan={Plan.sandbox} + planRange={PlanRange.monthly} + canPay + />, + ) + + expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument() + }) + + it('should not show "most popular" badge for non-professional plans', () => { + render( + <CloudPlanItem + plan={Plan.team} + currentPlan={Plan.sandbox} + planRange={PlanRange.monthly} + canPay + />, + ) + + expect(screen.queryByText('billing.plansCommon.mostPopular')).not.toBeInTheDocument() + }) + it('should disable CTA when workspace already on higher tier', () => { render( <CloudPlanItem @@ -192,5 +218,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() + }) }) }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/billing/pricing/plans/cloud-plan-item/list/index.spec.tsx rename to web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx index bc33798482..5a06509355 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import { Plan } from '../../../../type' -import List from './index' +import { Plan } from '../../../../../type' +import List from '../index' describe('CloudPlanItem/List', () => { it('should show sandbox specific quotas', () => { diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.spec.tsx rename to web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx index d4dc82d71b..e1aada80f8 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import Item from './index' +import Item from '../index' describe('Item', () => { beforeEach(() => { @@ -9,13 +9,10 @@ describe('Item', () => { // Rendering the plan item row describe('Rendering', () => { it('should render the provided label when tooltip is absent', () => { - // Arrange const label = 'Monthly credits' - // Act const { container } = render(<Item label={label} />) - // Assert expect(screen.getByText(label)).toBeInTheDocument() expect(container.querySelector('.group')).toBeNull() }) @@ -24,27 +21,21 @@ describe('Item', () => { // Toggling the optional tooltip indicator describe('Tooltip behavior', () => { it('should render tooltip content when tooltip text is provided', () => { - // Arrange const label = 'Workspace seats' const tooltip = 'Seats define how many teammates can join the workspace.' - // Act const { container } = render(<Item label={label} tooltip={tooltip} />) - // Assert expect(screen.getByText(label)).toBeInTheDocument() expect(screen.getByText(tooltip)).toBeInTheDocument() expect(container.querySelector('.group')).not.toBeNull() }) it('should treat an empty tooltip string as absent', () => { - // Arrange const label = 'Vector storage' - // Act const { container } = render(<Item label={label} tooltip="" />) - // Assert expect(screen.getByText(label)).toBeInTheDocument() expect(container.querySelector('.group')).toBeNull() }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx similarity index 88% rename from web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.spec.tsx rename to web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx index f13162b00d..86e4cb1061 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/__tests__/tooltip.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import Tooltip from './tooltip' +import Tooltip from '../tooltip' describe('Tooltip', () => { beforeEach(() => { @@ -9,26 +9,20 @@ describe('Tooltip', () => { // Rendering the info tooltip container describe('Rendering', () => { it('should render the content panel when provide with text', () => { - // Arrange const content = 'Usage resets on the first day of every month.' - // Act render(<Tooltip content={content} />) - // Assert expect(() => screen.getByText(content)).not.toThrow() }) }) describe('Icon rendering', () => { it('should render the icon when provided with content', () => { - // Arrange const content = 'Tooltips explain each plan detail.' - // Act render(<Tooltip content={content} />) - // Assert expect(screen.getByTestId('tooltip-icon')).toBeInTheDocument() }) }) @@ -36,7 +30,6 @@ describe('Tooltip', () => { // Handling empty strings while keeping structure consistent describe('Edge cases', () => { it('should render without crashing when passed empty content', () => { - // Arrange const content = '' // Act and Assert diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/button.spec.tsx similarity index 94% rename from web/app/components/billing/pricing/plans/self-hosted-plan-item/button.spec.tsx rename to web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/button.spec.tsx index 35a484e7c3..78d5bf898d 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.spec.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/button.spec.tsx @@ -3,8 +3,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import useTheme from '@/hooks/use-theme' import { Theme } from '@/types/app' -import { SelfHostedPlan } from '../../../type' -import Button from './button' +import { SelfHostedPlan } from '../../../../type' +import Button from '../button' vi.mock('@/hooks/use-theme') diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx similarity index 75% rename from web/app/components/billing/pricing/plans/self-hosted-plan-item/index.spec.tsx rename to web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx index 801bd2b6d7..9507cdef3c 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx @@ -2,30 +2,21 @@ import type { Mock } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { useAppContext } from '@/context/app-context' -import Toast from '../../../../base/toast' -import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config' -import { SelfHostedPlan } from '../../../type' -import SelfHostedPlanItem from './index' +import Toast from '../../../../../base/toast' +import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../../config' +import { SelfHostedPlan } from '../../../../type' +import SelfHostedPlanItem from '../index' -const featuresTranslations: Record<string, string[]> = { - 'billing.plans.community.features': ['community-feature-1', 'community-feature-2'], - 'billing.plans.premium.features': ['premium-feature-1'], - 'billing.plans.enterprise.features': ['enterprise-feature-1'], -} - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - const prefix = options?.ns ? `${options.ns}.` : '' - if (options?.returnObjects) - return featuresTranslations[`${prefix}${key}`] || [] - return `${prefix}${key}` - }, - }), - Trans: ({ i18nKey, ns }: { i18nKey: string, ns?: string }) => <span>{ns ? `${ns}.${i18nKey}` : i18nKey}</span>, +vi.mock('../list', () => ({ + default: ({ plan }: { plan: string }) => ( + <div data-testid={`list-${plan}`}> + List for + {plan} + </div> + ), })) -vi.mock('../../../../base/toast', () => ({ +vi.mock('../../../../../base/toast', () => ({ default: { notify: vi.fn(), }, @@ -35,7 +26,7 @@ vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) -vi.mock('../../assets', () => ({ +vi.mock('../../../assets', () => ({ Community: () => <div>Community Icon</div>, Premium: () => <div>Premium Icon</div>, Enterprise: () => <div>Enterprise Icon</div>, @@ -63,6 +54,12 @@ beforeAll(() => { }) }) +beforeEach(() => { + vi.clearAllMocks() + mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true }) + assignedHref = '' +}) + afterAll(() => { Object.defineProperty(window, 'location', { configurable: true, @@ -70,14 +67,7 @@ afterAll(() => { }) }) -beforeEach(() => { - vi.clearAllMocks() - mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true }) - assignedHref = '' -}) - describe('SelfHostedPlanItem', () => { - // Copy rendering for each plan describe('Rendering', () => { it('should display community plan info', () => { render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />) @@ -85,8 +75,7 @@ describe('SelfHostedPlanItem', () => { expect(screen.getByText('billing.plans.community.name')).toBeInTheDocument() expect(screen.getByText('billing.plans.community.description')).toBeInTheDocument() expect(screen.getByText('billing.plans.community.price')).toBeInTheDocument() - expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument() - expect(screen.getByText('community-feature-1')).toBeInTheDocument() + expect(screen.getByTestId('list-community')).toBeInTheDocument() }) it('should show premium extras such as cloud provider notice', () => { @@ -97,7 +86,6 @@ describe('SelfHostedPlanItem', () => { }) }) - // CTA behavior for each plan describe('CTA interactions', () => { it('should show toast when non-manager tries to proceed', () => { mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false }) diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/index.spec.tsx new file mode 100644 index 0000000000..dfe7905fd6 --- /dev/null +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/index.spec.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { SelfHostedPlan } from '@/app/components/billing/type' +import { createReactI18nextMock } from '@/test/i18n-mock' +import List from '../index' + +// Override global i18n mock to support returnObjects: true for feature arrays +vi.mock('react-i18next', () => createReactI18nextMock({ + 'billing.plans.community.features': ['Feature A', 'Feature B'], +})) + +describe('SelfHostedPlanItem/List', () => { + it('should render plan info', () => { + render(<List plan={SelfHostedPlan.community} />) + + expect(screen.getByText('plans.community.includesTitle')).toBeInTheDocument() + expect(screen.getByText('Feature A')).toBeInTheDocument() + expect(screen.getByText('Feature B')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/item.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/item.spec.tsx new file mode 100644 index 0000000000..b9a7ae7d59 --- /dev/null +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/item.spec.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import Item from '../item' + +describe('SelfHostedPlanItem/List/Item', () => { + it('should display provided feature label', () => { + const { container } = render(<Item label="Dedicated support" />) + + expect(screen.getByText('Dedicated support')).toBeInTheDocument() + expect(container.querySelector('svg')).not.toBeNull() + }) + + it('should render the check icon', () => { + const { container } = render(<Item label="Custom branding" />) + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + expect(svg).toHaveClass('size-4') + }) + + it('should render different labels correctly', () => { + const { rerender } = render(<Item label="Feature A" />) + expect(screen.getByText('Feature A')).toBeInTheDocument() + + rerender(<Item label="Feature B" />) + expect(screen.getByText('Feature B')).toBeInTheDocument() + expect(screen.queryByText('Feature A')).not.toBeInTheDocument() + }) + + it('should render with empty label', () => { + const { container } = render(<Item label="" />) + + expect(container.querySelector('svg')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.spec.tsx deleted file mode 100644 index 5bb37e9712..0000000000 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.spec.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { render, screen } from '@testing-library/react' -import * as React from 'react' -import { SelfHostedPlan } from '@/app/components/billing/type' -import List from './index' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - if (options?.returnObjects) - return ['Feature A', 'Feature B'] - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), - Trans: ({ i18nKey, ns }: { i18nKey: string, ns?: string }) => <span>{ns ? `${ns}.${i18nKey}` : i18nKey}</span>, -})) - -describe('SelfHostedPlanItem/List', () => { - it('should render plan info', () => { - render(<List plan={SelfHostedPlan.community} />) - - expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument() - expect(screen.getByText('Feature A')).toBeInTheDocument() - expect(screen.getByText('Feature B')).toBeInTheDocument() - }) -}) diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.spec.tsx deleted file mode 100644 index 2f2957fb9d..0000000000 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.spec.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { render, screen } from '@testing-library/react' -import * as React from 'react' -import Item from './item' - -describe('SelfHostedPlanItem/List/Item', () => { - it('should display provided feature label', () => { - const { container } = render(<Item label="Dedicated support" />) - - expect(screen.getByText('Dedicated support')).toBeInTheDocument() - expect(container.querySelector('svg')).not.toBeNull() - }) -}) diff --git a/web/app/components/billing/priority-label/index.spec.tsx b/web/app/components/billing/priority-label/__tests__/index.spec.tsx similarity index 87% rename from web/app/components/billing/priority-label/index.spec.tsx rename to web/app/components/billing/priority-label/__tests__/index.spec.tsx index 0d176d1611..ef613d76b8 100644 --- a/web/app/components/billing/priority-label/index.spec.tsx +++ b/web/app/components/billing/priority-label/__tests__/index.spec.tsx @@ -2,8 +2,8 @@ import type { Mock } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import { createMockPlan } from '@/__mocks__/provider-context' import { useProviderContext } from '@/context/provider-context' -import { Plan } from '../type' -import PriorityLabel from './index' +import { Plan } from '../../type' +import PriorityLabel from '../index' vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), @@ -20,16 +20,12 @@ describe('PriorityLabel', () => { vi.clearAllMocks() }) - // Rendering: basic label output for sandbox plan. describe('Rendering', () => { it('should render the standard priority label when plan is sandbox', () => { - // Arrange setupPlan(Plan.sandbox) - // Act render(<PriorityLabel />) - // Assert expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument() }) }) @@ -37,13 +33,10 @@ describe('PriorityLabel', () => { // Props: custom class name applied to the label container. describe('Props', () => { it('should apply custom className to the label container', () => { - // Arrange setupPlan(Plan.sandbox) - // Act render(<PriorityLabel className="custom-class" />) - // Assert const label = screen.getByText('billing.plansCommon.priority.standard').closest('div') expect(label).toHaveClass('custom-class') }) @@ -52,54 +45,53 @@ describe('PriorityLabel', () => { // Plan types: label text and icon visibility for different plans. describe('Plan Types', () => { it('should render priority label and icon when plan is professional', () => { - // Arrange setupPlan(Plan.professional) - // Act const { container } = render(<PriorityLabel />) - // Assert expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument() expect(container.querySelector('svg')).toBeInTheDocument() }) it('should render top priority label and icon when plan is team', () => { - // Arrange setupPlan(Plan.team) - // Act const { container } = render(<PriorityLabel />) - // Assert expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument() expect(container.querySelector('svg')).toBeInTheDocument() }) it('should render standard label without icon when plan is sandbox', () => { - // Arrange setupPlan(Plan.sandbox) - // Act const { container } = render(<PriorityLabel />) - // Assert expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument() expect(container.querySelector('svg')).not.toBeInTheDocument() }) }) - // Edge cases: tooltip content varies by priority level. + // Enterprise plan tests + describe('Enterprise Plan', () => { + it('should render top-priority label with icon for enterprise plan', () => { + setupPlan(Plan.enterprise) + + const { container } = render(<PriorityLabel />) + + expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + }) + describe('Edge Cases', () => { it('should show the tip text when priority is not top priority', async () => { - // Arrange setupPlan(Plan.sandbox) - // Act render(<PriorityLabel />) const label = screen.getByText('billing.plansCommon.priority.standard').closest('div') fireEvent.mouseEnter(label as HTMLElement) - // Assert expect(await screen.findByText( 'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard', )).toBeInTheDocument() @@ -107,15 +99,12 @@ describe('PriorityLabel', () => { }) it('should hide the tip text when priority is top priority', async () => { - // Arrange setupPlan(Plan.enterprise) - // Act render(<PriorityLabel />) const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div') fireEvent.mouseEnter(label as HTMLElement) - // Assert expect(await screen.findByText( 'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority', )).toBeInTheDocument() diff --git a/web/app/components/billing/progress-bar/index.spec.tsx b/web/app/components/billing/progress-bar/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/billing/progress-bar/index.spec.tsx rename to web/app/components/billing/progress-bar/__tests__/index.spec.tsx index 4eb66dcf79..ffdbfb30e7 100644 --- a/web/app/components/billing/progress-bar/index.spec.tsx +++ b/web/app/components/billing/progress-bar/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import ProgressBar from './index' +import ProgressBar from '../index' describe('ProgressBar', () => { describe('Normal Mode (determinate)', () => { diff --git a/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx b/web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx similarity index 55% rename from web/app/components/billing/trigger-events-limit-modal/index.spec.tsx rename to web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx index b2335c9820..b0ada6ff16 100644 --- a/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx +++ b/web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import TriggerEventsLimitModal from './index' +import TriggerEventsLimitModal from '../index' const mockOnClose = vi.fn() const mockOnUpgrade = vi.fn() @@ -16,8 +16,7 @@ const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, descr )) vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - // eslint-disable-next-line ts/no-explicit-any - default: (props: any) => planUpgradeModalMock(props), + default: (props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => planUpgradeModalMock(props), })) describe('TriggerEventsLimitModal', () => { @@ -66,4 +65,53 @@ describe('TriggerEventsLimitModal', () => { expect(planUpgradeModalMock).toHaveBeenCalled() expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false') }) + + it('renders reset info when resetInDays is provided', () => { + render( + <TriggerEventsLimitModal + show + onClose={mockOnClose} + onUpgrade={mockOnUpgrade} + usage={18000} + total={20000} + resetInDays={7} + />, + ) + + expect(screen.getByText('billing.triggerLimitModal.usageTitle')).toBeInTheDocument() + expect(screen.getByText('18000')).toBeInTheDocument() + expect(screen.getByText('20000')).toBeInTheDocument() + }) + + it('passes correct title and description translations', () => { + render( + <TriggerEventsLimitModal + show + onClose={mockOnClose} + onUpgrade={mockOnUpgrade} + usage={0} + total={0} + />, + ) + + const modal = screen.getByTestId('plan-upgrade-modal') + expect(modal.getAttribute('data-title')).toBe('billing.triggerLimitModal.title') + expect(modal.getAttribute('data-description')).toBe('billing.triggerLimitModal.description') + }) + + it('passes onClose and onUpgrade callbacks to PlanUpgradeModal', () => { + render( + <TriggerEventsLimitModal + show + onClose={mockOnClose} + onUpgrade={mockOnUpgrade} + usage={0} + total={0} + />, + ) + + const passedProps = planUpgradeModalMock.mock.calls[0][0] + expect(passedProps.onClose).toBe(mockOnClose) + expect(passedProps.onUpgrade).toBe(mockOnUpgrade) + }) }) diff --git a/web/app/components/billing/upgrade-btn/index.spec.tsx b/web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx similarity index 79% rename from web/app/components/billing/upgrade-btn/index.spec.tsx rename to web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx index a9db6c946f..1840f1d33c 100644 --- a/web/app/components/billing/upgrade-btn/index.spec.tsx +++ b/web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import type { Mock } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import UpgradeBtn from './index' +import UpgradeBtn from '../index' // ✅ Import real project components (DO NOT mock these) // PremiumBadge, Button, SparklesSoft are all base components @@ -14,146 +14,117 @@ vi.mock('@/context/modal-context', () => ({ }), })) -// Mock gtag for tracking tests +// Typed window accessor for gtag tracking tests +const gtagWindow = window as unknown as Record<string, Mock | undefined> let mockGtag: Mock | undefined describe('UpgradeBtn', () => { beforeEach(() => { vi.clearAllMocks() mockGtag = vi.fn() - ;(window as any).gtag = mockGtag + gtagWindow.gtag = mockGtag }) afterEach(() => { - delete (window as any).gtag + delete gtagWindow.gtag }) // Rendering tests (REQUIRED) describe('Rendering', () => { it('should render without crashing with default props', () => { - // Act render(<UpgradeBtn />) - // Assert - should render with default text expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should render premium badge by default', () => { - // Act render(<UpgradeBtn />) - // Assert - PremiumBadge renders with text content expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should render plain button when isPlain is true', () => { - // Act render(<UpgradeBtn isPlain />) - // Assert - Button should be rendered with plain text const button = screen.getByRole('button') expect(button).toBeInTheDocument() expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument() }) it('should render short text when isShort is true', () => { - // Act render(<UpgradeBtn isShort />) - // Assert expect(screen.getByText(/billing\.upgradeBtn\.encourageShort/i)).toBeInTheDocument() }) it('should render custom label when labelKey is provided', () => { - // Act - render(<UpgradeBtn labelKey={'custom.label.key' as any} />) + render(<UpgradeBtn labelKey="triggerLimitModal.upgrade" />) - // Assert - expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument() + expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument() }) it('should render custom label in plain button when labelKey is provided with isPlain', () => { - // Act - render(<UpgradeBtn isPlain labelKey={'custom.label.key' as any} />) + render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />) - // Assert const button = screen.getByRole('button') expect(button).toBeInTheDocument() - expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument() + expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument() }) }) // Props tests (REQUIRED) describe('Props', () => { it('should apply custom className to premium badge', () => { - // Arrange const customClass = 'custom-upgrade-btn' - // Act const { container } = render(<UpgradeBtn className={customClass} />) - // Assert - Check the root element has the custom class const rootElement = container.firstChild as HTMLElement expect(rootElement).toHaveClass(customClass) }) it('should apply custom className to plain button', () => { - // Arrange const customClass = 'custom-button-class' - // Act render(<UpgradeBtn isPlain className={customClass} />) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass(customClass) }) it('should apply custom style to premium badge', () => { - // Arrange const customStyle = { padding: '10px' } - // Act const { container } = render(<UpgradeBtn style={customStyle} />) - // Assert const rootElement = container.firstChild as HTMLElement expect(rootElement).toHaveStyle(customStyle) }) it('should apply custom style to plain button', () => { - // Arrange const customStyle = { margin: '5px' } - // Act render(<UpgradeBtn isPlain style={customStyle} />) - // Assert const button = screen.getByRole('button') expect(button).toHaveStyle(customStyle) }) it('should render with size "s"', () => { - // Act render(<UpgradeBtn size="s" />) - // Assert - Component renders successfully with size prop expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should render with size "m" by default', () => { - // Act render(<UpgradeBtn />) - // Assert - Component renders successfully expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should render with size "custom"', () => { - // Act render(<UpgradeBtn size="custom" />) - // Assert - Component renders successfully with custom size expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) }) @@ -161,72 +132,57 @@ describe('UpgradeBtn', () => { // User Interactions describe('User Interactions', () => { it('should call custom onClick when provided and premium badge is clicked', async () => { - // Arrange const user = userEvent.setup() const handleClick = vi.fn() - // Act render(<UpgradeBtn onClick={handleClick} />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert expect(handleClick).toHaveBeenCalledTimes(1) expect(mockSetShowPricingModal).not.toHaveBeenCalled() }) it('should call custom onClick when provided and plain button is clicked', async () => { - // Arrange const user = userEvent.setup() const handleClick = vi.fn() - // Act render(<UpgradeBtn isPlain onClick={handleClick} />) const button = screen.getByRole('button') await user.click(button) - // Assert expect(handleClick).toHaveBeenCalledTimes(1) expect(mockSetShowPricingModal).not.toHaveBeenCalled() }) it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => { - // Arrange const user = userEvent.setup() - // Act render(<UpgradeBtn />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) it('should open pricing modal when no custom onClick is provided and plain button is clicked', async () => { - // Arrange const user = userEvent.setup() - // Act render(<UpgradeBtn isPlain />) const button = screen.getByRole('button') await user.click(button) - // Assert expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) it('should track gtag event when loc is provided and badge is clicked', async () => { - // Arrange const user = userEvent.setup() const loc = 'header-navigation' - // Act render(<UpgradeBtn loc={loc} />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert expect(mockGtag).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc, @@ -234,16 +190,13 @@ describe('UpgradeBtn', () => { }) it('should track gtag event when loc is provided and plain button is clicked', async () => { - // Arrange const user = userEvent.setup() const loc = 'footer-section' - // Act render(<UpgradeBtn isPlain loc={loc} />) const button = screen.getByRole('button') await user.click(button) - // Assert expect(mockGtag).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc, @@ -251,44 +204,35 @@ describe('UpgradeBtn', () => { }) it('should not track gtag event when loc is not provided', async () => { - // Arrange const user = userEvent.setup() - // Act render(<UpgradeBtn />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert expect(mockGtag).not.toHaveBeenCalled() }) it('should not track gtag event when gtag is not available', async () => { - // Arrange const user = userEvent.setup() - delete (window as any).gtag + delete gtagWindow.gtag - // Act render(<UpgradeBtn loc="test-location" />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert - should not throw error expect(mockGtag).not.toHaveBeenCalled() }) it('should call both custom onClick and track gtag when both are provided', async () => { - // Arrange const user = userEvent.setup() const handleClick = vi.fn() const loc = 'settings-page' - // Act render(<UpgradeBtn onClick={handleClick} loc={loc} />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert expect(handleClick).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { @@ -300,121 +244,95 @@ describe('UpgradeBtn', () => { // Edge Cases (REQUIRED) describe('Edge Cases', () => { it('should handle undefined className', () => { - // Act render(<UpgradeBtn className={undefined} />) - // Assert - should render without error expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should handle undefined style', () => { - // Act render(<UpgradeBtn style={undefined} />) - // Assert - should render without error expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should handle undefined onClick', async () => { - // Arrange const user = userEvent.setup() - // Act render(<UpgradeBtn onClick={undefined} />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert - should fall back to setShowPricingModal expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) it('should handle undefined loc', async () => { - // Arrange const user = userEvent.setup() - // Act render(<UpgradeBtn loc={undefined} />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert - should not attempt to track gtag expect(mockGtag).not.toHaveBeenCalled() }) it('should handle undefined labelKey', () => { - // Act render(<UpgradeBtn labelKey={undefined} />) - // Assert - should use default label expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should handle empty string className', () => { - // Act render(<UpgradeBtn className="" />) - // Assert expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) it('should handle empty string loc', async () => { - // Arrange const user = userEvent.setup() - // Act render(<UpgradeBtn loc="" />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert - empty loc should not trigger gtag expect(mockGtag).not.toHaveBeenCalled() }) - it('should handle empty string labelKey', () => { - // Act - render(<UpgradeBtn labelKey={'' as any} />) + it('should handle labelKey with isShort - labelKey takes precedence', () => { + render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />) - // Assert - empty labelKey is falsy, so it falls back to default label - expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() + expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument() + expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument() }) }) // Prop Combinations describe('Prop Combinations', () => { it('should handle isPlain with isShort', () => { - // Act render(<UpgradeBtn isPlain isShort />) - // Assert - isShort should not affect plain button text expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument() }) it('should handle isPlain with custom labelKey', () => { - // Act - render(<UpgradeBtn isPlain labelKey={'custom.key' as any} />) + render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />) - // Assert - labelKey should override plain text - expect(screen.getByText(/custom\.key/i)).toBeInTheDocument() + expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument() expect(screen.queryByText(/billing\.upgradeBtn\.plain/i)).not.toBeInTheDocument() }) it('should handle isShort with custom labelKey', () => { - // Act - render(<UpgradeBtn isShort labelKey={'custom.short.key' as any} />) + render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />) - // Assert - labelKey should override isShort behavior - expect(screen.getByText(/custom\.short\.key/i)).toBeInTheDocument() + expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument() expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument() }) it('should handle all custom props together', async () => { - // Arrange const user = userEvent.setup() const handleClick = vi.fn() const customStyle = { margin: '10px' } const customClass = 'all-custom' - // Act const { container } = render( <UpgradeBtn className={customClass} @@ -423,17 +341,16 @@ describe('UpgradeBtn', () => { isShort onClick={handleClick} loc="test-loc" - labelKey={'custom.all' as any} + labelKey="triggerLimitModal.description" />, ) - const badge = screen.getByText(/custom\.all/i) + const badge = screen.getByText(/triggerLimitModal\.description/i) await user.click(badge) - // Assert const rootElement = container.firstChild as HTMLElement expect(rootElement).toHaveClass(customClass) expect(rootElement).toHaveStyle(customStyle) - expect(screen.getByText(/custom\.all/i)).toBeInTheDocument() + expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument() expect(handleClick).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'test-loc', @@ -444,11 +361,9 @@ describe('UpgradeBtn', () => { // Accessibility Tests describe('Accessibility', () => { it('should be keyboard accessible with plain button', async () => { - // Arrange const user = userEvent.setup() const handleClick = vi.fn() - // Act render(<UpgradeBtn isPlain onClick={handleClick} />) const button = screen.getByRole('button') @@ -459,47 +374,38 @@ describe('UpgradeBtn', () => { // Press Enter await user.keyboard('{Enter}') - // Assert expect(handleClick).toHaveBeenCalledTimes(1) }) it('should be keyboard accessible with Space key', async () => { - // Arrange const user = userEvent.setup() const handleClick = vi.fn() - // Act render(<UpgradeBtn isPlain onClick={handleClick} />) // Tab to button and press Space await user.tab() await user.keyboard(' ') - // Assert expect(handleClick).toHaveBeenCalledTimes(1) }) it('should be clickable for premium badge variant', async () => { - // Arrange const user = userEvent.setup() const handleClick = vi.fn() - // Act render(<UpgradeBtn onClick={handleClick} />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) // Click badge await user.click(badge) - // Assert expect(handleClick).toHaveBeenCalledTimes(1) }) it('should have proper button role when isPlain is true', () => { - // Act render(<UpgradeBtn isPlain />) - // Assert - Plain button should have button role const button = screen.getByRole('button') expect(button).toBeInTheDocument() }) @@ -508,31 +414,25 @@ describe('UpgradeBtn', () => { // Integration Tests describe('Integration', () => { it('should work with modal context for pricing modal', async () => { - // Arrange const user = userEvent.setup() - // Act render(<UpgradeBtn />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert await waitFor(() => { expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) }) it('should integrate onClick with analytics tracking', async () => { - // Arrange const user = userEvent.setup() const handleClick = vi.fn() - // Act render(<UpgradeBtn onClick={handleClick} loc="integration-test" />) const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) await user.click(badge) - // Assert - Both onClick and gtag should be called await waitFor(() => { expect(handleClick).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { diff --git a/web/app/components/billing/usage-info/__tests__/apps-info.spec.tsx b/web/app/components/billing/usage-info/__tests__/apps-info.spec.tsx new file mode 100644 index 0000000000..48aa132431 --- /dev/null +++ b/web/app/components/billing/usage-info/__tests__/apps-info.spec.tsx @@ -0,0 +1,67 @@ +import { render, screen } from '@testing-library/react' +import { defaultPlan } from '../../config' +import AppsInfo from '../apps-info' + +const mockProviderContext = vi.fn() + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockProviderContext(), +})) + +describe('AppsInfo', () => { + beforeEach(() => { + vi.clearAllMocks() + mockProviderContext.mockReturnValue({ + plan: { + ...defaultPlan, + usage: { ...defaultPlan.usage, buildApps: 7 }, + total: { ...defaultPlan.total, buildApps: 15 }, + }, + }) + }) + + it('renders build apps usage information with context data', () => { + render(<AppsInfo className="apps-info-class" />) + + expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument() + expect(screen.getByText('7')).toBeInTheDocument() + expect(screen.getByText('15')).toBeInTheDocument() + expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument() + }) + + it('renders without className', () => { + render(<AppsInfo />) + + expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument() + }) + + it('renders zero usage correctly', () => { + mockProviderContext.mockReturnValue({ + plan: { + ...defaultPlan, + usage: { ...defaultPlan.usage, buildApps: 0 }, + total: { ...defaultPlan.total, buildApps: 5 }, + }, + }) + + render(<AppsInfo />) + + expect(screen.getByText('0')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('renders when usage equals total (at capacity)', () => { + mockProviderContext.mockReturnValue({ + plan: { + ...defaultPlan, + usage: { ...defaultPlan.usage, buildApps: 10 }, + total: { ...defaultPlan.total, buildApps: 10 }, + }, + }) + + render(<AppsInfo />) + + const tens = screen.getAllByText('10') + expect(tens.length).toBe(2) + }) +}) diff --git a/web/app/components/billing/usage-info/index.spec.tsx b/web/app/components/billing/usage-info/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/billing/usage-info/index.spec.tsx rename to web/app/components/billing/usage-info/__tests__/index.spec.tsx index 02b22c87c7..b781ef7746 100644 --- a/web/app/components/billing/usage-info/index.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' -import { NUM_INFINITE } from '../config' -import UsageInfo from './index' +import { NUM_INFINITE } from '../../config' +import UsageInfo from '../index' const TestIcon = () => <span data-testid="usage-icon" /> diff --git a/web/app/components/billing/usage-info/vector-space-info.spec.tsx b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx similarity index 98% rename from web/app/components/billing/usage-info/vector-space-info.spec.tsx rename to web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx index a811cc9a09..3da67f02af 100644 --- a/web/app/components/billing/usage-info/vector-space-info.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' -import { defaultPlan } from '../config' -import { Plan } from '../type' -import VectorSpaceInfo from './vector-space-info' +import { defaultPlan } from '../../config' +import { Plan } from '../../type' +import VectorSpaceInfo from '../vector-space-info' // Mock provider context with configurable plan let mockPlanType = Plan.sandbox diff --git a/web/app/components/billing/usage-info/apps-info.spec.tsx b/web/app/components/billing/usage-info/apps-info.spec.tsx deleted file mode 100644 index 7289b474e5..0000000000 --- a/web/app/components/billing/usage-info/apps-info.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { defaultPlan } from '../config' -import AppsInfo from './apps-info' - -const appsUsage = 7 -const appsTotal = 15 - -const mockPlan = { - ...defaultPlan, - usage: { - ...defaultPlan.usage, - buildApps: appsUsage, - }, - total: { - ...defaultPlan.total, - buildApps: appsTotal, - }, -} - -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => ({ - plan: mockPlan, - }), -})) - -describe('AppsInfo', () => { - it('renders build apps usage information with context data', () => { - render(<AppsInfo className="apps-info-class" />) - - expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument() - expect(screen.getByText(`${appsUsage}`)).toBeInTheDocument() - expect(screen.getByText(`${appsTotal}`)).toBeInTheDocument() - expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument() - }) -}) diff --git a/web/app/components/billing/utils/index.spec.ts b/web/app/components/billing/utils/__tests__/index.spec.ts similarity index 98% rename from web/app/components/billing/utils/index.spec.ts rename to web/app/components/billing/utils/__tests__/index.spec.ts index d85155d6ff..115da91db7 100644 --- a/web/app/components/billing/utils/index.spec.ts +++ b/web/app/components/billing/utils/__tests__/index.spec.ts @@ -1,6 +1,6 @@ -import type { CurrentPlanInfoBackend } from '../type' -import { DocumentProcessingPriority, Plan } from '../type' -import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from './index' +import type { CurrentPlanInfoBackend } from '../../type' +import { DocumentProcessingPriority, Plan } from '../../type' +import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from '../index' describe('billing utils', () => { // parseVectorSpaceToMB tests diff --git a/web/app/components/billing/vector-space-full/index.spec.tsx b/web/app/components/billing/vector-space-full/__tests__/index.spec.tsx similarity index 69% rename from web/app/components/billing/vector-space-full/index.spec.tsx rename to web/app/components/billing/vector-space-full/__tests__/index.spec.tsx index 375ac54c22..b1ef0104a0 100644 --- a/web/app/components/billing/vector-space-full/index.spec.tsx +++ b/web/app/components/billing/vector-space-full/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import VectorSpaceFull from './index' +import VectorSpaceFull from '../index' type VectorProviderGlobal = typeof globalThis & { __vectorProviderContext?: ReturnType<typeof vi.fn> @@ -17,12 +17,12 @@ vi.mock('@/context/provider-context', () => { } }) -vi.mock('../upgrade-btn', () => ({ +vi.mock('../../upgrade-btn', () => ({ default: () => <button data-testid="vector-upgrade-btn" type="button">Upgrade</button>, })) // Mock utils to control threshold and plan limits -vi.mock('../utils', () => ({ +vi.mock('../../utils', () => ({ getPlanVectorSpaceLimitMB: (planType: string) => { // Return 5 for sandbox (threshold) and 100 for team if (planType === 'sandbox') @@ -66,4 +66,26 @@ describe('VectorSpaceFull', () => { expect(screen.getByText('8')).toBeInTheDocument() expect(screen.getByText('100MB')).toBeInTheDocument() }) + + it('renders vector space info section', () => { + render(<VectorSpaceFull />) + + expect(screen.getByText('billing.usagePage.vectorSpace')).toBeInTheDocument() + }) + + it('renders with sandbox plan', () => { + const globals = getVectorGlobal() + globals.__vectorProviderContext?.mockReturnValue({ + plan: { + type: 'sandbox', + usage: { vectorSpace: 2 }, + total: { vectorSpace: 50 }, + }, + }) + + render(<VectorSpaceFull />) + + expect(screen.getByText('billing.vectorSpace.fullTip')).toBeInTheDocument() + expect(screen.getByTestId('vector-upgrade-btn')).toBeInTheDocument() + }) }) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index a2c0cb0d94..3a518544e8 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -2997,11 +2997,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 @@ -3022,11 +3017,6 @@ "count": 1 } }, - "app/components/billing/pricing/plans/self-hosted-plan-item/index.spec.tsx": { - "test/prefer-hooks-in-order": { - "count": 1 - } - }, "app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 4 @@ -3050,11 +3040,6 @@ "count": 1 } }, - "app/components/billing/upgrade-btn/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 9 - } - }, "app/components/billing/upgrade-btn/index.tsx": { "ts/no-explicit-any": { "count": 3 From bfdc39510b247c24a6b991af90cf0e7e5facad00 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 12 Feb 2026 10:05:43 +0800 Subject: [PATCH 06/18] test: add unit and integration tests for share, develop, and goto-anything modules (#32246) Co-authored-by: CodingOnStar <hanxujiang@dify.com> --- .../develop/api-key-management-flow.test.tsx | 192 ++++++++++++ .../develop/develop-page-flow.test.tsx | 241 +++++++++++++++ .../slash-command-modes.test.tsx | 8 +- .../text-generation-run-batch-flow.test.tsx | 121 ++++++++ .../text-generation-run-once-flow.test.tsx | 218 ++++++++++++++ .../{ => __tests__}/ApiServer.spec.tsx | 8 +- .../develop/{ => __tests__}/code.spec.tsx | 18 +- .../components/develop/__tests__/doc.spec.tsx | 206 +++++++++++++ .../develop/{ => __tests__}/index.spec.tsx | 22 +- .../develop/{ => __tests__}/md.spec.tsx | 2 +- .../develop/{ => __tests__}/tag.spec.tsx | 5 +- web/app/components/develop/index.tsx | 2 +- .../{ => __tests__}/input-copy.spec.tsx | 156 +++++----- .../secret-key-button.spec.tsx | 16 +- .../secret-key-generate.spec.tsx | 181 ++++++------ .../{ => __tests__}/secret-key-modal.spec.tsx | 248 ++++++++-------- .../{ => __tests__}/command-selector.spec.tsx | 8 +- .../{ => __tests__}/context.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 32 +- .../actions/__tests__/app.spec.ts | 71 +++++ .../actions/__tests__/index.spec.ts | 276 ++++++++++++++++++ .../actions/__tests__/knowledge.spec.ts | 93 ++++++ .../actions/__tests__/plugin.spec.ts | 72 +++++ .../commands/__tests__/command-bus.spec.ts | 68 +++++ .../__tests__/direct-commands.spec.ts | 212 ++++++++++++++ .../commands/__tests__/language.spec.ts | 89 ++++++ .../commands/__tests__/registry.spec.ts | 267 +++++++++++++++++ .../actions/commands/__tests__/theme.spec.ts | 73 +++++ .../actions/commands/__tests__/zen.spec.ts | 84 ++++++ .../{ => __tests__}/empty-state.spec.tsx | 19 +- .../{ => __tests__}/footer.spec.tsx | 18 +- .../components/__tests__/result-item.spec.tsx | 82 ++++++ .../components/__tests__/result-list.spec.tsx | 86 ++++++ .../{ => __tests__}/search-input.spec.tsx | 8 +- .../use-goto-anything-modal.spec.ts | 19 +- .../use-goto-anything-navigation.spec.ts | 18 +- .../use-goto-anything-results.spec.ts | 7 +- .../use-goto-anything-search.spec.ts | 11 +- .../share/{ => __tests__}/utils.spec.ts | 2 +- .../{ => __tests__}/info-modal.spec.tsx | 71 ++--- .../{ => __tests__}/menu-dropdown.spec.tsx | 48 ++- .../no-data/{ => __tests__}/index.spec.tsx | 2 +- .../run-batch/{ => __tests__}/index.spec.tsx | 6 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../csv-reader/{ => __tests__}/index.spec.tsx | 13 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../run-once/{ => __tests__}/index.spec.tsx | 7 +- web/eslint-suppressions.json | 10 - web/vitest.config.ts | 11 + 49 files changed, 2880 insertions(+), 555 deletions(-) create mode 100644 web/__tests__/develop/api-key-management-flow.test.tsx create mode 100644 web/__tests__/develop/develop-page-flow.test.tsx create mode 100644 web/__tests__/share/text-generation-run-batch-flow.test.tsx create mode 100644 web/__tests__/share/text-generation-run-once-flow.test.tsx rename web/app/components/develop/{ => __tests__}/ApiServer.spec.tsx (96%) rename web/app/components/develop/{ => __tests__}/code.spec.tsx (97%) create mode 100644 web/app/components/develop/__tests__/doc.spec.tsx rename web/app/components/develop/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/develop/{ => __tests__}/md.spec.tsx (99%) rename web/app/components/develop/{ => __tests__}/tag.spec.tsx (97%) rename web/app/components/develop/secret-key/{ => __tests__}/input-copy.spec.tsx (60%) rename web/app/components/develop/secret-key/{ => __tests__}/secret-key-button.spec.tsx (94%) rename web/app/components/develop/secret-key/{ => __tests__}/secret-key-generate.spec.tsx (53%) rename web/app/components/develop/secret-key/{ => __tests__}/secret-key-modal.spec.tsx (69%) rename web/app/components/goto-anything/{ => __tests__}/command-selector.spec.tsx (96%) rename web/app/components/goto-anything/{ => __tests__}/context.spec.tsx (96%) rename web/app/components/goto-anything/{ => __tests__}/index.spec.tsx (95%) create mode 100644 web/app/components/goto-anything/actions/__tests__/app.spec.ts create mode 100644 web/app/components/goto-anything/actions/__tests__/index.spec.ts create mode 100644 web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts create mode 100644 web/app/components/goto-anything/actions/__tests__/plugin.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/zen.spec.ts rename web/app/components/goto-anything/components/{ => __tests__}/empty-state.spec.tsx (88%) rename web/app/components/goto-anything/components/{ => __tests__}/footer.spec.tsx (92%) create mode 100644 web/app/components/goto-anything/components/__tests__/result-item.spec.tsx create mode 100644 web/app/components/goto-anything/components/__tests__/result-list.spec.tsx rename web/app/components/goto-anything/components/{ => __tests__}/search-input.spec.tsx (96%) rename web/app/components/goto-anything/hooks/{ => __tests__}/use-goto-anything-modal.spec.ts (91%) rename web/app/components/goto-anything/hooks/{ => __tests__}/use-goto-anything-navigation.spec.ts (95%) rename web/app/components/goto-anything/hooks/{ => __tests__}/use-goto-anything-results.spec.ts (97%) rename web/app/components/goto-anything/hooks/{ => __tests__}/use-goto-anything-search.spec.ts (96%) rename web/app/components/share/{ => __tests__}/utils.spec.ts (97%) rename web/app/components/share/text-generation/{ => __tests__}/info-modal.spec.tsx (73%) rename web/app/components/share/text-generation/{ => __tests__}/menu-dropdown.spec.tsx (80%) rename web/app/components/share/text-generation/no-data/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/share/text-generation/run-batch/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/share/text-generation/run-batch/csv-download/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/share/text-generation/run-batch/csv-reader/{ => __tests__}/index.spec.tsx (81%) rename web/app/components/share/text-generation/run-batch/res-download/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/share/text-generation/run-once/{ => __tests__}/index.spec.tsx (98%) diff --git a/web/__tests__/develop/api-key-management-flow.test.tsx b/web/__tests__/develop/api-key-management-flow.test.tsx new file mode 100644 index 0000000000..188b8e6304 --- /dev/null +++ b/web/__tests__/develop/api-key-management-flow.test.tsx @@ -0,0 +1,192 @@ +/** + * Integration test: API Key management flow + * + * Tests the cross-component interaction: + * ApiServer → SecretKeyButton → SecretKeyModal + * + * Renders real ApiServer, SecretKeyButton, and SecretKeyModal together + * with only service-layer mocks. Deep modal interactions (create/delete) + * are covered by unit tests in secret-key-modal.spec.tsx. + */ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ApiServer from '@/app/components/develop/ApiServer' + +// ---------- fake timers (HeadlessUI Dialog transitions) ---------- +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) + +afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() +}) + +async function flushUI() { + await act(async () => { + vi.runAllTimers() + }) +} + +// ---------- mocks ---------- + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + currentWorkspace: { id: 'ws-1', name: 'Workspace' }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceEditor: true, + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: vi.fn((val: number) => `Time:${val}`), + formatDate: vi.fn((val: string) => `Date:${val}`), + }), +})) + +vi.mock('@/service/apps', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/service/datasets', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +const mockApiKeys = vi.fn().mockReturnValue({ data: [] }) +const mockIsLoading = vi.fn().mockReturnValue(false) + +vi.mock('@/service/use-apps', () => ({ + useAppApiKeys: () => ({ + data: mockApiKeys(), + isLoading: mockIsLoading(), + }), + useInvalidateAppApiKeys: () => vi.fn(), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetApiKeys: () => ({ data: null, isLoading: false }), + useInvalidateDatasetApiKeys: () => vi.fn(), +})) + +// ---------- tests ---------- + +describe('API Key management flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApiKeys.mockReturnValue({ data: [] }) + mockIsLoading.mockReturnValue(false) + }) + + it('ApiServer renders URL, status badge, and API Key button', () => { + render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />) + + expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument() + expect(screen.getByText('appApi.ok')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKey')).toBeInTheDocument() + }) + + it('clicking API Key button opens SecretKeyModal with real modal content', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />) + + // Click API Key button (rendered by SecretKeyButton) + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + // SecretKeyModal should render with real HeadlessUI Dialog + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument() + }) + }) + + it('modal shows loading state when API keys are being fetched', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + mockIsLoading.mockReturnValue(true) + + render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />) + + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + // Loading indicator should be present + expect(document.body.querySelector('[role="status"]')).toBeInTheDocument() + }) + + it('modal can be closed by clicking X icon', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />) + + // Open modal + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + // Click X icon to close + const closeIcon = document.body.querySelector('svg.cursor-pointer') + expect(closeIcon).toBeInTheDocument() + + await act(async () => { + await user.click(closeIcon!) + }) + await flushUI() + + // Modal should close + await waitFor(() => { + expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument() + }) + }) + + it('renders correctly with different API URLs', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + const { rerender } = render( + <ApiServer apiBaseUrl="http://localhost:5001/v1" appId="app-dev" />, + ) + + expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument() + + // Open modal and verify it works with the same appId + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + // Close modal, update URL and re-verify + const xIcon = document.body.querySelector('svg.cursor-pointer') + await act(async () => { + await user.click(xIcon!) + }) + await flushUI() + + rerender( + <ApiServer apiBaseUrl="https://api.production.com/v1" appId="app-prod" />, + ) + + expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument() + }) +}) diff --git a/web/__tests__/develop/develop-page-flow.test.tsx b/web/__tests__/develop/develop-page-flow.test.tsx new file mode 100644 index 0000000000..6b46ee025c --- /dev/null +++ b/web/__tests__/develop/develop-page-flow.test.tsx @@ -0,0 +1,241 @@ +/** + * Integration test: DevelopMain page flow + * + * Tests the full page lifecycle: + * Loading state → App loaded → Header (ApiServer) + Content (Doc) rendered + * + * Uses real DevelopMain, ApiServer, and Doc components with minimal mocks. + */ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DevelopMain from '@/app/components/develop' +import { AppModeEnum, Theme } from '@/types/app' + +// ---------- fake timers ---------- +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) + +afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() +}) + +async function flushUI() { + await act(async () => { + vi.runAllTimers() + }) +} + +// ---------- store mock ---------- + +let storeAppDetail: unknown + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record<string, unknown>) => unknown) => { + return selector({ appDetail: storeAppDetail }) + }, +})) + +// ---------- Doc dependencies ---------- + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: Theme.light }), +})) + +vi.mock('@/i18n-config/language', () => ({ + LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'], +})) + +// ---------- SecretKeyModal dependencies ---------- + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + currentWorkspace: { id: 'ws-1', name: 'Workspace' }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceEditor: true, + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: vi.fn((val: number) => `Time:${val}`), + formatDate: vi.fn((val: string) => `Date:${val}`), + }), +})) + +vi.mock('@/service/apps', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/service/datasets', () => ({ + createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }), + delApikey: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/service/use-apps', () => ({ + useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }), + useInvalidateAppApiKeys: () => vi.fn(), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetApiKeys: () => ({ data: null, isLoading: false }), + useInvalidateDatasetApiKeys: () => vi.fn(), +})) + +// ---------- tests ---------- + +describe('DevelopMain page flow', () => { + beforeEach(() => { + vi.clearAllMocks() + storeAppDetail = undefined + }) + + it('should show loading indicator when appDetail is not available', () => { + storeAppDetail = undefined + render(<DevelopMain appId="app-1" />) + + expect(screen.getByRole('status')).toBeInTheDocument() + // No content should be visible + expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument() + }) + + it('should render full page when appDetail is loaded', () => { + storeAppDetail = { + id: 'app-1', + name: 'Test App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.CHAT, + } + + render(<DevelopMain appId="app-1" />) + + // ApiServer section should be visible + expect(screen.getByText('appApi.apiServer')).toBeInTheDocument() + expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument() + expect(screen.getByText('appApi.ok')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKey')).toBeInTheDocument() + + // Loading should NOT be visible + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('should render Doc component with correct app mode template', () => { + storeAppDetail = { + id: 'app-1', + name: 'Chat App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.CHAT, + } + + const { container } = render(<DevelopMain appId="app-1" />) + + // Doc renders an article element with prose classes + const article = container.querySelector('article') + expect(article).toBeInTheDocument() + expect(article?.className).toContain('prose') + }) + + it('should transition from loading to content when appDetail becomes available', () => { + // Start with no data + storeAppDetail = undefined + const { rerender } = render(<DevelopMain appId="app-1" />) + expect(screen.getByRole('status')).toBeInTheDocument() + + // Simulate store update + storeAppDetail = { + id: 'app-1', + name: 'My App', + api_base_url: 'https://api.example.com/v1', + mode: AppModeEnum.COMPLETION, + } + rerender(<DevelopMain appId="app-1" />) + + // Content should now be visible + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument() + }) + + it('should open API key modal from the page', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + storeAppDetail = { + id: 'app-1', + name: 'Test App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.WORKFLOW, + } + + render(<DevelopMain appId="app-1" />) + + // Click API Key button in the header + await act(async () => { + await user.click(screen.getByText('appApi.apiKey')) + }) + await flushUI() + + // SecretKeyModal should open + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + }) + + it('should render correctly for different app modes', () => { + const modes = [ + AppModeEnum.CHAT, + AppModeEnum.COMPLETION, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.WORKFLOW, + ] + + for (const mode of modes) { + storeAppDetail = { + id: 'app-1', + name: `${mode} App`, + api_base_url: 'https://api.test.com/v1', + mode, + } + + const { container, unmount } = render(<DevelopMain appId="app-1" />) + + // ApiServer should always be present + expect(screen.getByText('appApi.apiServer')).toBeInTheDocument() + + // Doc should render an article + expect(container.querySelector('article')).toBeInTheDocument() + + unmount() + } + }) + + it('should have correct page layout structure', () => { + storeAppDetail = { + id: 'app-1', + name: 'Test App', + api_base_url: 'https://api.test.com/v1', + mode: AppModeEnum.CHAT, + } + + render(<DevelopMain appId="app-1" />) + + // Main container: flex column with full height + const mainDiv = screen.getByTestId('develop-main') + expect(mainDiv.className).toContain('flex') + expect(mainDiv.className).toContain('flex-col') + expect(mainDiv.className).toContain('h-full') + + // Header section with border + const header = mainDiv.querySelector('.border-b') + expect(header).toBeInTheDocument() + + // Content section with overflow scroll + const content = mainDiv.querySelector('.overflow-auto') + expect(content).toBeInTheDocument() + }) +}) diff --git a/web/__tests__/goto-anything/slash-command-modes.test.tsx b/web/__tests__/goto-anything/slash-command-modes.test.tsx index 9a2f7c1eac..38c965e383 100644 --- a/web/__tests__/goto-anything/slash-command-modes.test.tsx +++ b/web/__tests__/goto-anything/slash-command-modes.test.tsx @@ -49,14 +49,14 @@ describe('Slash Command Dual-Mode System', () => { beforeEach(() => { vi.clearAllMocks() - ;(slashCommandRegistry as any).findCommand = vi.fn((name: string) => { + vi.mocked(slashCommandRegistry.findCommand).mockImplementation((name: string) => { if (name === 'docs') return mockDirectCommand if (name === 'theme') return mockSubmenuCommand - return null + return undefined }) - ;(slashCommandRegistry as any).getAllCommands = vi.fn(() => [ + vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ mockDirectCommand, mockSubmenuCommand, ]) @@ -147,7 +147,7 @@ describe('Slash Command Dual-Mode System', () => { unregister: vi.fn(), } - ;(slashCommandRegistry as any).findCommand = vi.fn(() => commandWithoutMode) + vi.mocked(slashCommandRegistry.findCommand).mockReturnValue(commandWithoutMode) const handler = slashCommandRegistry.findCommand('test') // Default behavior should be submenu when mode is not specified diff --git a/web/__tests__/share/text-generation-run-batch-flow.test.tsx b/web/__tests__/share/text-generation-run-batch-flow.test.tsx new file mode 100644 index 0000000000..a511527e16 --- /dev/null +++ b/web/__tests__/share/text-generation-run-batch-flow.test.tsx @@ -0,0 +1,121 @@ +/** + * Integration test: RunBatch CSV upload → Run flow + * + * Tests the complete user journey: + * Upload CSV → parse → enable run → click run → results finish → run again + */ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import RunBatch from '@/app/components/share/text-generation/run-batch' + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(() => 'pc'), + MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, +})) + +// Capture the onParsed callback from CSVReader to simulate CSV uploads +let capturedOnParsed: ((data: string[][]) => void) | undefined + +vi.mock('@/app/components/share/text-generation/run-batch/csv-reader', () => ({ + default: ({ onParsed }: { onParsed: (data: string[][]) => void }) => { + capturedOnParsed = onParsed + return <div data-testid="csv-reader">CSV Reader</div> + }, +})) + +vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({ + default: ({ vars }: { vars: { name: string }[] }) => ( + <div data-testid="csv-download"> + {vars.map(v => v.name).join(', ')} + </div> + ), +})) + +describe('RunBatch – integration flow', () => { + const vars = [{ name: 'prompt' }, { name: 'context' }] + + beforeEach(() => { + capturedOnParsed = undefined + vi.clearAllMocks() + }) + + it('full lifecycle: upload CSV → run → finish → run again', async () => { + const onSend = vi.fn() + + const { rerender } = render( + <RunBatch vars={vars} onSend={onSend} isAllFinished />, + ) + + // Phase 1 – verify child components rendered + expect(screen.getByTestId('csv-reader')).toBeInTheDocument() + expect(screen.getByTestId('csv-download')).toHaveTextContent('prompt, context') + + // Run button should be disabled before CSV is parsed + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + expect(runButton).toBeDisabled() + + // Phase 2 – simulate CSV upload + const csvData = [ + ['prompt', 'context'], + ['Hello', 'World'], + ['Goodbye', 'Moon'], + ] + await act(async () => { + capturedOnParsed?.(csvData) + }) + + // Run button should now be enabled + await waitFor(() => { + expect(runButton).not.toBeDisabled() + }) + + // Phase 3 – click run + fireEvent.click(runButton) + expect(onSend).toHaveBeenCalledTimes(1) + expect(onSend).toHaveBeenCalledWith(csvData) + + // Phase 4 – simulate results still running + rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />) + expect(runButton).toBeDisabled() + + // Phase 5 – results finish → can run again + rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished />) + await waitFor(() => { + expect(runButton).not.toBeDisabled() + }) + + onSend.mockClear() + fireEvent.click(runButton) + expect(onSend).toHaveBeenCalledTimes(1) + }) + + it('should remain disabled when CSV not uploaded even if all finished', () => { + const onSend = vi.fn() + render(<RunBatch vars={vars} onSend={onSend} isAllFinished />) + + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + expect(runButton).toBeDisabled() + + fireEvent.click(runButton) + expect(onSend).not.toHaveBeenCalled() + }) + + it('should show spinner icon when results are still running', async () => { + const onSend = vi.fn() + const { container } = render( + <RunBatch vars={vars} onSend={onSend} isAllFinished={false} />, + ) + + // Upload CSV first + await act(async () => { + capturedOnParsed?.([['data']]) + }) + + // Button disabled + spinning icon + const runButton = screen.getByRole('button', { name: 'share.generation.run' }) + expect(runButton).toBeDisabled() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass('animate-spin') + }) +}) diff --git a/web/__tests__/share/text-generation-run-once-flow.test.tsx b/web/__tests__/share/text-generation-run-once-flow.test.tsx new file mode 100644 index 0000000000..2a5d1b882c --- /dev/null +++ b/web/__tests__/share/text-generation-run-once-flow.test.tsx @@ -0,0 +1,218 @@ +/** + * Integration test: RunOnce form lifecycle + * + * Tests the complete user journey: + * Init defaults → edit fields → submit → running state → stop + */ +import type { InputValueTypes } from '@/app/components/share/text-generation/types' +import type { PromptConfig, PromptVariable } from '@/models/debug' +import type { SiteInfo } from '@/models/share' +import type { VisionSettings } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { useRef, useState } from 'react' +import RunOnce from '@/app/components/share/text-generation/run-once' +import { Resolution, TransferMethod } from '@/types/app' + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(() => 'pc'), + MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ value, onChange }: { value?: string, onChange?: (val: string) => void }) => ( + <textarea data-testid="code-editor" value={value ?? ''} onChange={e => onChange?.(e.target.value)} /> + ), +})) + +vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => ({ + default: () => <div data-testid="vision-uploader" />, +})) + +vi.mock('@/app/components/base/file-uploader', () => ({ + FileUploaderInAttachmentWrapper: () => <div data-testid="file-uploader" />, +})) + +// ----- helpers ----- + +const variable = (overrides: Partial<PromptVariable>): PromptVariable => ({ + key: 'k', + name: 'Name', + type: 'string', + required: true, + ...overrides, +}) + +const visionOff: VisionSettings = { + enabled: false, + number_limits: 0, + detail: Resolution.low, + transfer_methods: [TransferMethod.local_file], + image_file_size_limit: 5, +} + +const siteInfo: SiteInfo = { title: 'Test' } + +/** + * Stateful wrapper that mirrors what text-generation/index.tsx does: + * owns `inputs` state and passes an `inputsRef`. + */ +function Harness({ + promptConfig, + visionConfig = visionOff, + onSendSpy, + runControl = null, +}: { + promptConfig: PromptConfig + visionConfig?: VisionSettings + onSendSpy: () => void + runControl?: React.ComponentProps<typeof RunOnce>['runControl'] +}) { + const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({}) + const inputsRef = useRef<Record<string, InputValueTypes>>({}) + + return ( + <RunOnce + siteInfo={siteInfo} + promptConfig={promptConfig} + inputs={inputs} + inputsRef={inputsRef} + onInputsChange={(updated) => { + inputsRef.current = updated + setInputs(updated) + }} + onSend={onSendSpy} + visionConfig={visionConfig} + onVisionFilesChange={vi.fn()} + runControl={runControl} + /> + ) +} + +// ----- tests ----- + +describe('RunOnce – integration flow', () => { + it('full lifecycle: init → edit → submit → running → stop', async () => { + const onSend = vi.fn() + + const config: PromptConfig = { + prompt_template: 'tpl', + prompt_variables: [ + variable({ key: 'name', name: 'Name', type: 'string', default: '' }), + variable({ key: 'age', name: 'Age', type: 'number', default: '' }), + variable({ key: 'bio', name: 'Bio', type: 'paragraph', default: '' }), + ], + } + + // Phase 1 – render, wait for initialisation + const { rerender } = render( + <Harness promptConfig={config} onSendSpy={onSend} />, + ) + + await waitFor(() => { + expect(screen.getByPlaceholderText('Name')).toBeInTheDocument() + }) + + // Phase 2 – fill fields + fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value: 'Alice' } }) + fireEvent.change(screen.getByPlaceholderText('Age'), { target: { value: '30' } }) + fireEvent.change(screen.getByPlaceholderText('Bio'), { target: { value: 'Hello' } }) + + // Phase 3 – submit + fireEvent.click(screen.getByTestId('run-button')) + expect(onSend).toHaveBeenCalledTimes(1) + + // Phase 4 – simulate "running" state + const onStop = vi.fn() + rerender( + <Harness + promptConfig={config} + onSendSpy={onSend} + runControl={{ onStop, isStopping: false }} + />, + ) + + const stopBtn = screen.getByTestId('stop-button') + expect(stopBtn).toBeInTheDocument() + fireEvent.click(stopBtn) + expect(onStop).toHaveBeenCalledTimes(1) + + // Phase 5 – simulate "stopping" state + rerender( + <Harness + promptConfig={config} + onSendSpy={onSend} + runControl={{ onStop, isStopping: true }} + />, + ) + expect(screen.getByTestId('stop-button')).toBeDisabled() + }) + + it('clear resets all field types and allows re-submit', async () => { + const onSend = vi.fn() + + const config: PromptConfig = { + prompt_template: 'tpl', + prompt_variables: [ + variable({ key: 'q', name: 'Question', type: 'string', default: 'Hi' }), + variable({ key: 'flag', name: 'Flag', type: 'checkbox' }), + ], + } + + render(<Harness promptConfig={config} onSendSpy={onSend} />) + + await waitFor(() => { + expect(screen.getByPlaceholderText('Question')).toHaveValue('Hi') + }) + + // Clear all + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) + + await waitFor(() => { + expect(screen.getByPlaceholderText('Question')).toHaveValue('') + }) + + // Re-fill and submit + fireEvent.change(screen.getByPlaceholderText('Question'), { target: { value: 'New' } }) + fireEvent.click(screen.getByTestId('run-button')) + expect(onSend).toHaveBeenCalledTimes(1) + }) + + it('mixed input types: string + select + json_object', async () => { + const onSend = vi.fn() + + const config: PromptConfig = { + prompt_template: 'tpl', + prompt_variables: [ + variable({ key: 'txt', name: 'Text', type: 'string', default: '' }), + variable({ + key: 'sel', + name: 'Dropdown', + type: 'select', + options: ['A', 'B'], + default: 'A', + }), + variable({ + key: 'json', + name: 'JSON', + type: 'json_object' as PromptVariable['type'], + }), + ], + } + + render(<Harness promptConfig={config} onSendSpy={onSend} />) + + await waitFor(() => { + expect(screen.getByText('Text')).toBeInTheDocument() + expect(screen.getByText('Dropdown')).toBeInTheDocument() + expect(screen.getByText('JSON')).toBeInTheDocument() + }) + + // Edit text & json + fireEvent.change(screen.getByPlaceholderText('Text'), { target: { value: 'hello' } }) + fireEvent.change(screen.getByTestId('code-editor'), { target: { value: '{"a":1}' } }) + + fireEvent.click(screen.getByTestId('run-button')) + expect(onSend).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/develop/ApiServer.spec.tsx b/web/app/components/develop/__tests__/ApiServer.spec.tsx similarity index 96% rename from web/app/components/develop/ApiServer.spec.tsx rename to web/app/components/develop/__tests__/ApiServer.spec.tsx index 097eac578a..fb007b75c6 100644 --- a/web/app/components/develop/ApiServer.spec.tsx +++ b/web/app/components/develop/__tests__/ApiServer.spec.tsx @@ -1,9 +1,8 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { act } from 'react' -import ApiServer from './ApiServer' +import ApiServer from '../ApiServer' -// Mock the secret-key-modal since it involves complex API interactions vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( isShow ? <div data-testid="secret-key-modal"><button onClick={onClose}>Close Modal</button></div> : null @@ -38,7 +37,6 @@ describe('ApiServer', () => { it('should render CopyFeedback component', () => { render(<ApiServer {...defaultProps} />) - // CopyFeedback renders a button for copying const copyButtons = screen.getAllByRole('button') expect(copyButtons.length).toBeGreaterThan(0) }) @@ -90,7 +88,6 @@ describe('ApiServer', () => { const user = userEvent.setup() render(<ApiServer {...defaultProps} appId="app-123" />) - // Open modal const apiKeyButton = screen.getByText('appApi.apiKey') await act(async () => { await user.click(apiKeyButton) @@ -98,7 +95,6 @@ describe('ApiServer', () => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - // Close modal const closeButton = screen.getByText('Close Modal') await act(async () => { await user.click(closeButton) @@ -196,9 +192,7 @@ describe('ApiServer', () => { describe('SecretKeyButton styling', () => { it('should have shrink-0 class to prevent shrinking', () => { render(<ApiServer {...defaultProps} appId="app-123" />) - // The SecretKeyButton wraps a Button component const button = screen.getByRole('button', { name: /apiKey/i }) - // Check parent container has shrink-0 const buttonContainer = button.closest('.shrink-0') expect(buttonContainer).toBeInTheDocument() }) diff --git a/web/app/components/develop/code.spec.tsx b/web/app/components/develop/__tests__/code.spec.tsx similarity index 97% rename from web/app/components/develop/code.spec.tsx rename to web/app/components/develop/__tests__/code.spec.tsx index b279c41a66..0b57a54294 100644 --- a/web/app/components/develop/code.spec.tsx +++ b/web/app/components/develop/__tests__/code.spec.tsx @@ -1,8 +1,7 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { Code, CodeGroup, Embed, Pre } from './code' +import { Code, CodeGroup, Embed, Pre } from '../code' -// Mock the clipboard utility vi.mock('@/utils/clipboard', () => ({ writeTextToClipboard: vi.fn().mockResolvedValue(undefined), })) @@ -155,6 +154,9 @@ describe('code.tsx components', () => { <pre><code>fallback</code></pre> </CodeGroup>, ) + await act(async () => { + vi.runAllTimers() + }) const tab2 = screen.getByRole('tab', { name: 'Tab2' }) await act(async () => { @@ -229,7 +231,6 @@ describe('code.tsx components', () => { ) expect(screen.getByText('POST')).toBeInTheDocument() expect(screen.getByText('/api/create')).toBeInTheDocument() - // Separator should be present const separator = container.querySelector('.rounded-full.bg-zinc-500') expect(separator).toBeInTheDocument() }) @@ -264,6 +265,9 @@ describe('code.tsx components', () => { <pre><code>fallback</code></pre> </CodeGroup>, ) + await act(async () => { + vi.runAllTimers() + }) const copyButton = screen.getByRole('button') await act(async () => { @@ -285,6 +289,9 @@ describe('code.tsx components', () => { <pre><code>fallback</code></pre> </CodeGroup>, ) + await act(async () => { + vi.runAllTimers() + }) const copyButton = screen.getByRole('button') await act(async () => { @@ -295,7 +302,6 @@ describe('code.tsx components', () => { expect(screen.getByText('Copied!')).toBeInTheDocument() }) - // Advance time past the timeout await act(async () => { vi.advanceTimersByTime(1500) }) @@ -358,7 +364,6 @@ describe('code.tsx components', () => { <pre><code>code content</code></pre> </Pre>, ) - // Should render within a CodeGroup structure const codeGroup = container.querySelector('.bg-zinc-900') expect(codeGroup).toBeInTheDocument() }) @@ -382,7 +387,6 @@ describe('code.tsx components', () => { </Pre> </CodeGroup>, ) - // The outer code should be rendered (from targetCode) expect(screen.getByText('outer code')).toBeInTheDocument() }) }) @@ -546,7 +550,6 @@ describe('code.tsx components', () => { <pre><code>fallback</code></pre> </CodeGroup>, ) - // Should render copy button even with empty code expect(screen.getByRole('button')).toBeInTheDocument() }) @@ -569,7 +572,6 @@ line3` <pre><code>fallback</code></pre> </CodeGroup>, ) - // Multiline code should be rendered - use a partial match expect(screen.getByText(/line1/)).toBeInTheDocument() expect(screen.getByText(/line2/)).toBeInTheDocument() expect(screen.getByText(/line3/)).toBeInTheDocument() diff --git a/web/app/components/develop/__tests__/doc.spec.tsx b/web/app/components/develop/__tests__/doc.spec.tsx new file mode 100644 index 0000000000..eaccdfe2f1 --- /dev/null +++ b/web/app/components/develop/__tests__/doc.spec.tsx @@ -0,0 +1,206 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AppModeEnum, Theme } from '@/types/app' +import Doc from '../doc' + +// The vitest mdx-stub plugin makes .mdx files parseable; these mocks replace +vi.mock('../template/template.en.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-completion-en" />, +})) +vi.mock('../template/template.zh.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-completion-zh" />, +})) +vi.mock('../template/template.ja.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-completion-ja" />, +})) +vi.mock('../template/template_chat.en.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-chat-en" />, +})) +vi.mock('../template/template_chat.zh.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-chat-zh" />, +})) +vi.mock('../template/template_chat.ja.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-chat-ja" />, +})) +vi.mock('../template/template_advanced_chat.en.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-advanced-chat-en" />, +})) +vi.mock('../template/template_advanced_chat.zh.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-advanced-chat-zh" />, +})) +vi.mock('../template/template_advanced_chat.ja.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-advanced-chat-ja" />, +})) +vi.mock('../template/template_workflow.en.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-workflow-en" />, +})) +vi.mock('../template/template_workflow.zh.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-workflow-zh" />, +})) +vi.mock('../template/template_workflow.ja.mdx', () => ({ + default: (_props: Record<string, unknown>) => <div data-testid="template-workflow-ja" />, +})) + +const mockLocale = vi.fn().mockReturnValue('en-US') +vi.mock('@/context/i18n', () => ({ + useLocale: () => mockLocale(), +})) + +const mockTheme = vi.fn().mockReturnValue(Theme.light) +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: mockTheme() }), +})) + +vi.mock('@/i18n-config/language', () => ({ + LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'], +})) + +describe('Doc', () => { + const makeAppDetail = (mode: AppModeEnum, variables: Array<{ key: string, name: string }> = []) => ({ + mode, + model_config: { + configs: { + prompt_variables: variables, + }, + }, + }) + + beforeEach(() => { + vi.clearAllMocks() + mockLocale.mockReturnValue('en-US') + mockTheme.mockReturnValue(Theme.light) + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue({ matches: false }), + }) + }) + + describe('template selection by app mode', () => { + it.each([ + [AppModeEnum.CHAT, 'template-chat-en'], + [AppModeEnum.AGENT_CHAT, 'template-chat-en'], + [AppModeEnum.ADVANCED_CHAT, 'template-advanced-chat-en'], + [AppModeEnum.WORKFLOW, 'template-workflow-en'], + [AppModeEnum.COMPLETION, 'template-completion-en'], + ])('should render correct EN template for mode %s', (mode, testId) => { + render(<Doc appDetail={makeAppDetail(mode)} />) + expect(screen.getByTestId(testId)).toBeInTheDocument() + }) + }) + + describe('template selection by locale', () => { + it('should render ZH template when locale is zh-Hans', () => { + mockLocale.mockReturnValue('zh-Hans') + render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />) + expect(screen.getByTestId('template-chat-zh')).toBeInTheDocument() + }) + + it('should render JA template when locale is ja-JP', () => { + mockLocale.mockReturnValue('ja-JP') + render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />) + expect(screen.getByTestId('template-chat-ja')).toBeInTheDocument() + }) + + it('should fall back to EN template for unsupported locales', () => { + mockLocale.mockReturnValue('fr-FR') + render(<Doc appDetail={makeAppDetail(AppModeEnum.COMPLETION)} />) + expect(screen.getByTestId('template-completion-en')).toBeInTheDocument() + }) + + it('should render ZH advanced-chat template', () => { + mockLocale.mockReturnValue('zh-Hans') + render(<Doc appDetail={makeAppDetail(AppModeEnum.ADVANCED_CHAT)} />) + expect(screen.getByTestId('template-advanced-chat-zh')).toBeInTheDocument() + }) + + it('should render JA workflow template', () => { + mockLocale.mockReturnValue('ja-JP') + render(<Doc appDetail={makeAppDetail(AppModeEnum.WORKFLOW)} />) + expect(screen.getByTestId('template-workflow-ja')).toBeInTheDocument() + }) + }) + + describe('null/undefined appDetail', () => { + it('should render nothing when appDetail has no mode', () => { + render(<Doc appDetail={{}} />) + expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument() + expect(screen.queryByTestId('template-chat-en')).not.toBeInTheDocument() + }) + + it('should render nothing when appDetail is null', () => { + render(<Doc appDetail={null} />) + expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument() + }) + }) + + describe('TOC toggle', () => { + it('should show collapsed TOC button by default on small screens', () => { + render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />) + expect(screen.getByLabelText('Open table of contents')).toBeInTheDocument() + }) + + it('should show expanded TOC on wide screens', () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue({ matches: true }), + }) + render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />) + expect(screen.getByText('appApi.develop.toc')).toBeInTheDocument() + expect(screen.getByLabelText('Close')).toBeInTheDocument() + }) + + it('should expand TOC when toggle button is clicked', async () => { + render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />) + const toggleBtn = screen.getByLabelText('Open table of contents') + await act(async () => { + fireEvent.click(toggleBtn) + }) + expect(screen.getByText('appApi.develop.toc')).toBeInTheDocument() + }) + + it('should collapse TOC when close button is clicked', async () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue({ matches: true }), + }) + render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />) + + const closeBtn = screen.getByLabelText('Close') + await act(async () => { + fireEvent.click(closeBtn) + }) + expect(screen.getByLabelText('Open table of contents')).toBeInTheDocument() + }) + }) + + describe('dark theme', () => { + it('should apply prose-invert class in dark mode', () => { + mockTheme.mockReturnValue(Theme.dark) + const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />) + const article = container.querySelector('article') + expect(article?.className).toContain('prose-invert') + }) + + it('should not apply prose-invert class in light mode', () => { + mockTheme.mockReturnValue(Theme.light) + const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />) + const article = container.querySelector('article') + expect(article?.className).not.toContain('prose-invert') + }) + }) + + describe('article structure', () => { + it('should render article with prose classes', () => { + const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.COMPLETION)} />) + const article = container.querySelector('article') + expect(article).toBeInTheDocument() + expect(article?.className).toContain('prose') + }) + + it('should render flex layout wrapper', () => { + const { container } = render(<Doc appDetail={makeAppDetail(AppModeEnum.CHAT)} />) + expect(container.querySelector('.flex')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/develop/index.spec.tsx b/web/app/components/develop/__tests__/index.spec.tsx similarity index 93% rename from web/app/components/develop/index.spec.tsx rename to web/app/components/develop/__tests__/index.spec.tsx index f90e33e691..f8653ef012 100644 --- a/web/app/components/develop/index.spec.tsx +++ b/web/app/components/develop/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ import { render, screen } from '@testing-library/react' -import DevelopMain from './index' +import DevelopMain from '../index' -// Mock the app store with a factory function to control state const mockAppDetailValue: { current: unknown } = { current: undefined } vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: unknown) => unknown) => { @@ -10,7 +9,6 @@ vi.mock('@/app/components/app/store', () => ({ }, })) -// Mock the Doc component since it has complex dependencies vi.mock('@/app/components/develop/doc', () => ({ default: ({ appDetail }: { appDetail: { name?: string } | null }) => ( <div data-testid="doc-component"> @@ -20,7 +18,6 @@ vi.mock('@/app/components/develop/doc', () => ({ ), })) -// Mock the ApiServer component vi.mock('@/app/components/develop/ApiServer', () => ({ default: ({ apiBaseUrl, appId }: { apiBaseUrl: string, appId: string }) => ( <div data-testid="api-server"> @@ -44,7 +41,6 @@ describe('DevelopMain', () => { mockAppDetailValue.current = undefined render(<DevelopMain appId="app-123" />) - // Loading component renders with role="status" expect(screen.getByRole('status')).toBeInTheDocument() }) @@ -128,27 +124,27 @@ describe('DevelopMain', () => { }) it('should have flex column layout', () => { - const { container } = render(<DevelopMain appId="app-123" />) - const mainContainer = container.firstChild as HTMLElement + render(<DevelopMain appId="app-123" />) + const mainContainer = screen.getByTestId('develop-main') expect(mainContainer.className).toContain('flex') expect(mainContainer.className).toContain('flex-col') }) it('should have relative positioning', () => { - const { container } = render(<DevelopMain appId="app-123" />) - const mainContainer = container.firstChild as HTMLElement + render(<DevelopMain appId="app-123" />) + const mainContainer = screen.getByTestId('develop-main') expect(mainContainer.className).toContain('relative') }) it('should have full height', () => { - const { container } = render(<DevelopMain appId="app-123" />) - const mainContainer = container.firstChild as HTMLElement + render(<DevelopMain appId="app-123" />) + const mainContainer = screen.getByTestId('develop-main') expect(mainContainer.className).toContain('h-full') }) it('should have overflow-hidden', () => { - const { container } = render(<DevelopMain appId="app-123" />) - const mainContainer = container.firstChild as HTMLElement + render(<DevelopMain appId="app-123" />) + const mainContainer = screen.getByTestId('develop-main') expect(mainContainer.className).toContain('overflow-hidden') }) }) diff --git a/web/app/components/develop/md.spec.tsx b/web/app/components/develop/__tests__/md.spec.tsx similarity index 99% rename from web/app/components/develop/md.spec.tsx rename to web/app/components/develop/__tests__/md.spec.tsx index 8eab1c0ac8..6e5b9775d1 100644 --- a/web/app/components/develop/md.spec.tsx +++ b/web/app/components/develop/__tests__/md.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from './md' +import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from '../md' describe('md.tsx components', () => { describe('Heading', () => { diff --git a/web/app/components/develop/tag.spec.tsx b/web/app/components/develop/__tests__/tag.spec.tsx similarity index 97% rename from web/app/components/develop/tag.spec.tsx rename to web/app/components/develop/__tests__/tag.spec.tsx index 60a12040fa..9c01f4b0a2 100644 --- a/web/app/components/develop/tag.spec.tsx +++ b/web/app/components/develop/__tests__/tag.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { Tag } from './tag' +import { Tag } from '../tag' describe('Tag', () => { describe('rendering', () => { @@ -110,7 +110,6 @@ describe('Tag', () => { it('should apply small variant styles', () => { render(<Tag variant="small">GET</Tag>) const tag = screen.getByText('GET') - // Small variant should not have ring styles expect(tag.className).not.toContain('rounded-lg') expect(tag.className).not.toContain('ring-1') }) @@ -189,7 +188,6 @@ describe('Tag', () => { render(<Tag color="emerald" variant="small">TEST</Tag>) const tag = screen.getByText('TEST') expect(tag.className).toContain('text-emerald-500') - // Small variant should not have background/ring styles expect(tag.className).not.toContain('bg-emerald-400/10') expect(tag.className).not.toContain('ring-emerald-300') }) @@ -223,7 +221,6 @@ describe('Tag', () => { it('should correctly map PATCH to emerald (default)', () => { render(<Tag>PATCH</Tag>) const tag = screen.getByText('PATCH') - // PATCH is not in the valueColorMap, so it defaults to emerald expect(tag.className).toContain('text-emerald') }) diff --git a/web/app/components/develop/index.tsx b/web/app/components/develop/index.tsx index 70b84640fb..484092ae7d 100644 --- a/web/app/components/develop/index.tsx +++ b/web/app/components/develop/index.tsx @@ -20,7 +20,7 @@ const DevelopMain = ({ appId }: IDevelopMainProps) => { } return ( - <div className="relative flex h-full flex-col overflow-hidden"> + <div data-testid="develop-main" className="relative flex h-full flex-col overflow-hidden"> <div className="flex shrink-0 items-center justify-between border-b border-solid border-b-divider-regular px-6 py-2"> <div className="text-lg font-medium text-text-primary"></div> <ApiServer apiBaseUrl={appDetail.api_base_url} appId={appId} /> diff --git a/web/app/components/develop/secret-key/input-copy.spec.tsx b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx similarity index 60% rename from web/app/components/develop/secret-key/input-copy.spec.tsx rename to web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx index 0216f2bfad..e022faffc1 100644 --- a/web/app/components/develop/secret-key/input-copy.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx @@ -1,13 +1,20 @@ import { act, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import copy from 'copy-to-clipboard' -import InputCopy from './input-copy' +import InputCopy from '../input-copy' -// Mock copy-to-clipboard vi.mock('copy-to-clipboard', () => ({ default: vi.fn().mockReturnValue(true), })) +async function renderAndFlush(ui: React.ReactElement) { + const result = render(ui) + await act(async () => { + vi.runAllTimers() + }) + return result +} + describe('InputCopy', () => { beforeEach(() => { vi.clearAllMocks() @@ -20,19 +27,18 @@ describe('InputCopy', () => { }) describe('rendering', () => { - it('should render the value', () => { - render(<InputCopy value="test-api-key-12345" />) + it('should render the value', async () => { + await renderAndFlush(<InputCopy value="test-api-key-12345" />) expect(screen.getByText('test-api-key-12345')).toBeInTheDocument() }) - it('should render with empty value by default', () => { - render(<InputCopy />) - // Empty string should be rendered + it('should render with empty value by default', async () => { + await renderAndFlush(<InputCopy />) expect(screen.getByRole('button')).toBeInTheDocument() }) - it('should render children when provided', () => { - render( + it('should render children when provided', async () => { + await renderAndFlush( <InputCopy value="key"> <span data-testid="custom-child">Custom Content</span> </InputCopy>, @@ -40,53 +46,52 @@ describe('InputCopy', () => { expect(screen.getByTestId('custom-child')).toBeInTheDocument() }) - it('should render CopyFeedback component', () => { - render(<InputCopy value="test" />) - // CopyFeedback should render a button + it('should render CopyFeedback component', async () => { + await renderAndFlush(<InputCopy value="test" />) const buttons = screen.getAllByRole('button') expect(buttons.length).toBeGreaterThan(0) }) }) describe('styling', () => { - it('should apply custom className', () => { - const { container } = render(<InputCopy value="test" className="custom-class" />) + it('should apply custom className', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" className="custom-class" />) const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('custom-class') }) - it('should have flex layout', () => { - const { container } = render(<InputCopy value="test" />) + it('should have flex layout', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('flex') }) - it('should have items-center alignment', () => { - const { container } = render(<InputCopy value="test" />) + it('should have items-center alignment', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('items-center') }) - it('should have rounded-lg class', () => { - const { container } = render(<InputCopy value="test" />) + it('should have rounded-lg class', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('rounded-lg') }) - it('should have background class', () => { - const { container } = render(<InputCopy value="test" />) + it('should have background class', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('bg-components-input-bg-normal') }) - it('should have hover state', () => { - const { container } = render(<InputCopy value="test" />) + it('should have hover state', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('hover:bg-state-base-hover') }) - it('should have py-2 padding', () => { - const { container } = render(<InputCopy value="test" />) + it('should have py-2 padding', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('py-2') }) @@ -95,7 +100,7 @@ describe('InputCopy', () => { describe('copy functionality', () => { it('should copy value when clicked', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - render(<InputCopy value="copy-this-value" />) + await renderAndFlush(<InputCopy value="copy-this-value" />) const copyableArea = screen.getByText('copy-this-value') await act(async () => { @@ -107,20 +112,19 @@ describe('InputCopy', () => { it('should update copied state after clicking', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - render(<InputCopy value="test-value" />) + await renderAndFlush(<InputCopy value="test-value" />) const copyableArea = screen.getByText('test-value') await act(async () => { await user.click(copyableArea) }) - // Copy function should have been called expect(copy).toHaveBeenCalledWith('test-value') }) it('should reset copied state after timeout', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - render(<InputCopy value="test-value" />) + await renderAndFlush(<InputCopy value="test-value" />) const copyableArea = screen.getByText('test-value') await act(async () => { @@ -129,32 +133,29 @@ describe('InputCopy', () => { expect(copy).toHaveBeenCalledWith('test-value') - // Advance time to reset the copied state await act(async () => { vi.advanceTimersByTime(1500) }) - // Component should still be functional expect(screen.getByText('test-value')).toBeInTheDocument() }) - it('should render tooltip on value', () => { - render(<InputCopy value="test-value" />) - // Value should be wrapped in tooltip (tooltip shows on hover, not as visible text) + it('should render tooltip on value', async () => { + await renderAndFlush(<InputCopy value="test-value" />) const valueText = screen.getByText('test-value') expect(valueText).toBeInTheDocument() }) }) describe('tooltip', () => { - it('should render tooltip wrapper', () => { - render(<InputCopy value="test" />) + it('should render tooltip wrapper', async () => { + await renderAndFlush(<InputCopy value="test" />) const valueText = screen.getByText('test') expect(valueText).toBeInTheDocument() }) - it('should have cursor-pointer on clickable area', () => { - render(<InputCopy value="test" />) + it('should have cursor-pointer on clickable area', async () => { + await renderAndFlush(<InputCopy value="test" />) const valueText = screen.getByText('test') const clickableArea = valueText.closest('div[class*="cursor-pointer"]') expect(clickableArea).toBeInTheDocument() @@ -162,42 +163,42 @@ describe('InputCopy', () => { }) describe('divider', () => { - it('should render vertical divider', () => { - const { container } = render(<InputCopy value="test" />) + it('should render vertical divider', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const divider = container.querySelector('.bg-divider-regular') expect(divider).toBeInTheDocument() }) - it('should have correct divider dimensions', () => { - const { container } = render(<InputCopy value="test" />) + it('should have correct divider dimensions', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const divider = container.querySelector('.bg-divider-regular') expect(divider?.className).toContain('h-4') expect(divider?.className).toContain('w-px') }) - it('should have shrink-0 on divider', () => { - const { container } = render(<InputCopy value="test" />) + it('should have shrink-0 on divider', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const divider = container.querySelector('.bg-divider-regular') expect(divider?.className).toContain('shrink-0') }) }) describe('value display', () => { - it('should have truncate class for long values', () => { - render(<InputCopy value="very-long-api-key-value-that-might-overflow" />) + it('should have truncate class for long values', async () => { + await renderAndFlush(<InputCopy value="very-long-api-key-value-that-might-overflow" />) const valueText = screen.getByText('very-long-api-key-value-that-might-overflow') const container = valueText.closest('div[class*="truncate"]') expect(container).toBeInTheDocument() }) - it('should have text-secondary color on value', () => { - render(<InputCopy value="test-value" />) + it('should have text-secondary color on value', async () => { + await renderAndFlush(<InputCopy value="test-value" />) const valueText = screen.getByText('test-value') expect(valueText.className).toContain('text-text-secondary') }) - it('should have absolute positioning for overlay', () => { - render(<InputCopy value="test" />) + it('should have absolute positioning for overlay', async () => { + await renderAndFlush(<InputCopy value="test" />) const valueText = screen.getByText('test') const container = valueText.closest('div[class*="absolute"]') expect(container).toBeInTheDocument() @@ -205,22 +206,22 @@ describe('InputCopy', () => { }) describe('inner container', () => { - it('should have grow class on inner container', () => { - const { container } = render(<InputCopy value="test" />) + it('should have grow class on inner container', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const innerContainer = container.querySelector('.grow') expect(innerContainer).toBeInTheDocument() }) - it('should have h-5 height on inner container', () => { - const { container } = render(<InputCopy value="test" />) + it('should have h-5 height on inner container', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const innerContainer = container.querySelector('.h-5') expect(innerContainer).toBeInTheDocument() }) }) describe('with children', () => { - it('should render children before value', () => { - const { container } = render( + it('should render children before value', async () => { + const { container } = await renderAndFlush( <InputCopy value="key"> <span data-testid="prefix">Prefix:</span> </InputCopy>, @@ -229,8 +230,8 @@ describe('InputCopy', () => { expect(children).toBeInTheDocument() }) - it('should render both children and value', () => { - render( + it('should render both children and value', async () => { + await renderAndFlush( <InputCopy value="api-key"> <span>Label:</span> </InputCopy>, @@ -241,55 +242,53 @@ describe('InputCopy', () => { }) describe('CopyFeedback section', () => { - it('should have margin on CopyFeedback container', () => { - const { container } = render(<InputCopy value="test" />) + it('should have margin on CopyFeedback container', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const copyFeedbackContainer = container.querySelector('.mx-1') expect(copyFeedbackContainer).toBeInTheDocument() }) }) describe('relative container', () => { - it('should have relative positioning on value container', () => { - const { container } = render(<InputCopy value="test" />) + it('should have relative positioning on value container', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const relativeContainer = container.querySelector('.relative') expect(relativeContainer).toBeInTheDocument() }) - it('should have grow on value container', () => { - const { container } = render(<InputCopy value="test" />) - // Find the relative container that also has grow + it('should have grow on value container', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const valueContainer = container.querySelector('.relative.grow') expect(valueContainer).toBeInTheDocument() }) - it('should have full height on value container', () => { - const { container } = render(<InputCopy value="test" />) + it('should have full height on value container', async () => { + const { container } = await renderAndFlush(<InputCopy value="test" />) const valueContainer = container.querySelector('.relative.h-full') expect(valueContainer).toBeInTheDocument() }) }) describe('edge cases', () => { - it('should handle undefined value', () => { - render(<InputCopy value={undefined} />) - // Should not crash + it('should handle undefined value', async () => { + await renderAndFlush(<InputCopy value={undefined} />) expect(screen.getByRole('button')).toBeInTheDocument() }) - it('should handle empty string value', () => { - render(<InputCopy value="" />) + it('should handle empty string value', async () => { + await renderAndFlush(<InputCopy value="" />) expect(screen.getByRole('button')).toBeInTheDocument() }) - it('should handle very long values', () => { + it('should handle very long values', async () => { const longValue = 'a'.repeat(500) - render(<InputCopy value={longValue} />) + await renderAndFlush(<InputCopy value={longValue} />) expect(screen.getByText(longValue)).toBeInTheDocument() }) - it('should handle special characters in value', () => { + it('should handle special characters in value', async () => { const specialValue = 'key-with-special-chars!@#$%^&*()' - render(<InputCopy value={specialValue} />) + await renderAndFlush(<InputCopy value={specialValue} />) expect(screen.getByText(specialValue)).toBeInTheDocument() }) }) @@ -297,11 +296,10 @@ describe('InputCopy', () => { describe('multiple clicks', () => { it('should handle multiple rapid clicks', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - render(<InputCopy value="test" />) + await renderAndFlush(<InputCopy value="test" />) const copyableArea = screen.getByText('test') - // Click multiple times rapidly await act(async () => { await user.click(copyableArea) await user.click(copyableArea) diff --git a/web/app/components/develop/secret-key/secret-key-button.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx similarity index 94% rename from web/app/components/develop/secret-key/secret-key-button.spec.tsx rename to web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx index 4b4fbaab29..798d0dd16f 100644 --- a/web/app/components/develop/secret-key/secret-key-button.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx @@ -1,8 +1,7 @@ import { act, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import SecretKeyButton from './secret-key-button' +import SecretKeyButton from '../secret-key-button' -// Mock the SecretKeyModal since it has complex dependencies vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ default: ({ isShow, onClose, appId }: { isShow: boolean, onClose: () => void, appId?: string }) => ( isShow @@ -30,7 +29,6 @@ describe('SecretKeyButton', () => { it('should render the key icon', () => { const { container } = render(<SecretKeyButton />) - // RiKey2Line icon should be rendered as an svg const svg = container.querySelector('svg') expect(svg).toBeInTheDocument() }) @@ -58,7 +56,6 @@ describe('SecretKeyButton', () => { const user = userEvent.setup() render(<SecretKeyButton />) - // Open modal const button = screen.getByRole('button') await act(async () => { await user.click(button) @@ -66,7 +63,6 @@ describe('SecretKeyButton', () => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - // Close modal const closeButton = screen.getByTestId('close-modal') await act(async () => { await user.click(closeButton) @@ -81,20 +77,17 @@ describe('SecretKeyButton', () => { const button = screen.getByRole('button') - // Open await act(async () => { await user.click(button) }) expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() - // Close const closeButton = screen.getByTestId('close-modal') await act(async () => { await user.click(closeButton) }) expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - // Open again await act(async () => { await user.click(button) }) @@ -205,7 +198,6 @@ describe('SecretKeyButton', () => { const user = userEvent.setup() render(<SecretKeyButton />) - // Initially modal should not be visible expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() const button = screen.getByRole('button') @@ -213,7 +205,6 @@ describe('SecretKeyButton', () => { await user.click(button) }) - // Now modal should be visible expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() }) @@ -231,7 +222,6 @@ describe('SecretKeyButton', () => { await user.click(closeButton) }) - // Modal should be closed after clicking close expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() }) }) @@ -251,7 +241,6 @@ describe('SecretKeyButton', () => { button.focus() expect(document.activeElement).toBe(button) - // Press Enter to activate await act(async () => { await user.keyboard('{Enter}') }) @@ -273,20 +262,17 @@ describe('SecretKeyButton', () => { const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(2) - // Click first button await act(async () => { await user.click(buttons[0]) }) expect(screen.getByText('Modal for app-1')).toBeInTheDocument() - // Close first modal const closeButton = screen.getByTestId('close-modal') await act(async () => { await user.click(closeButton) }) - // Click second button await act(async () => { await user.click(buttons[1]) }) diff --git a/web/app/components/develop/secret-key/secret-key-generate.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx similarity index 53% rename from web/app/components/develop/secret-key/secret-key-generate.spec.tsx rename to web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx index 5988d6b7f3..7df86917ed 100644 --- a/web/app/components/develop/secret-key/secret-key-generate.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx @@ -1,15 +1,22 @@ import type { CreateApiKeyResponse } from '@/models/app' import { act, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import SecretKeyGenerateModal from './secret-key-generate' +import SecretKeyGenerateModal from '../secret-key-generate' -// Helper to create a valid CreateApiKeyResponse const createMockApiKey = (token: string): CreateApiKeyResponse => ({ id: 'mock-id', token, created_at: '2024-01-01T00:00:00Z', }) +async function renderModal(ui: React.ReactElement) { + const result = render(ui) + await act(async () => { + vi.runAllTimers() + }) + return result +} + describe('SecretKeyGenerateModal', () => { const defaultProps = { isShow: true, @@ -18,75 +25,78 @@ describe('SecretKeyGenerateModal', () => { beforeEach(() => { vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() }) describe('rendering when shown', () => { - it('should render the modal when isShow is true', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should render the modal when isShow is true', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() }) - it('should render the generate tips text', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should render the generate tips text', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() }) - it('should render the OK button', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should render the OK button', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) expect(screen.getByText('appApi.actionMsg.ok')).toBeInTheDocument() }) - it('should render the close icon', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) - // Modal renders via portal, so query from document.body + it('should render the close icon', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const closeIcon = document.body.querySelector('svg.cursor-pointer') expect(closeIcon).toBeInTheDocument() }) - it('should render InputCopy component', () => { - render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token-123')} />) + it('should render InputCopy component', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token-123')} />) expect(screen.getByText('test-token-123')).toBeInTheDocument() }) }) describe('rendering when hidden', () => { - it('should not render content when isShow is false', () => { - render(<SecretKeyGenerateModal {...defaultProps} isShow={false} />) + it('should not render content when isShow is false', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} isShow={false} />) expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument() }) }) describe('newKey prop', () => { - it('should display the token when newKey is provided', () => { - render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('sk-abc123xyz')} />) + it('should display the token when newKey is provided', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('sk-abc123xyz')} />) expect(screen.getByText('sk-abc123xyz')).toBeInTheDocument() }) - it('should handle undefined newKey', () => { - render(<SecretKeyGenerateModal {...defaultProps} newKey={undefined} />) - // Should not crash and modal should still render + it('should handle undefined newKey', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={undefined} />) expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() }) - it('should handle newKey with empty token', () => { - render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('')} />) + it('should handle newKey with empty token', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('')} />) expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() }) - it('should display long tokens correctly', () => { + it('should display long tokens correctly', async () => { const longToken = `sk-${'a'.repeat(100)}` - render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey(longToken)} />) + await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey(longToken)} />) expect(screen.getByText(longToken)).toBeInTheDocument() }) }) describe('close functionality', () => { it('should call onClose when X icon is clicked', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) const onClose = vi.fn() - render(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />) + await renderModal(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />) - // Modal renders via portal const closeIcon = document.body.querySelector('svg.cursor-pointer') expect(closeIcon).toBeInTheDocument() @@ -94,81 +104,60 @@ describe('SecretKeyGenerateModal', () => { await user.click(closeIcon!) }) - // HeadlessUI Dialog may trigger onClose multiple times (icon click handler + dialog close) expect(onClose).toHaveBeenCalled() }) it('should call onClose when OK button is clicked', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) const onClose = vi.fn() - render(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />) + await renderModal(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />) const okButton = screen.getByRole('button', { name: /ok/i }) await act(async () => { await user.click(okButton) }) - // HeadlessUI Dialog calls onClose both from button click and modal close expect(onClose).toHaveBeenCalled() }) }) describe('className prop', () => { - it('should apply custom className', () => { - render( + it('should apply custom className', async () => { + await renderModal( <SecretKeyGenerateModal {...defaultProps} className="custom-modal-class" />, ) - // Modal renders via portal const modal = document.body.querySelector('.custom-modal-class') expect(modal).toBeInTheDocument() }) - it('should apply shrink-0 class', () => { - render( + it('should apply shrink-0 class', async () => { + await renderModal( <SecretKeyGenerateModal {...defaultProps} className="shrink-0" />, ) - // Modal renders via portal const modal = document.body.querySelector('.shrink-0') expect(modal).toBeInTheDocument() }) }) describe('modal styling', () => { - it('should have px-8 padding', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) - // Modal renders via portal + it('should have px-8 padding', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const modal = document.body.querySelector('.px-8') expect(modal).toBeInTheDocument() }) }) describe('close icon styling', () => { - it('should have cursor-pointer class on close icon', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) - // Modal renders via portal + it('should have cursor-pointer class on close icon', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const closeIcon = document.body.querySelector('svg.cursor-pointer') expect(closeIcon).toBeInTheDocument() }) - - it('should have correct dimensions on close icon', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) - // Modal renders via portal - const closeIcon = document.body.querySelector('svg[class*="h-6"][class*="w-6"]') - expect(closeIcon).toBeInTheDocument() - }) - - it('should have tertiary text color on close icon', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) - // Modal renders via portal - const closeIcon = document.body.querySelector('svg[class*="text-text-tertiary"]') - expect(closeIcon).toBeInTheDocument() - }) }) describe('header section', () => { - it('should have flex justify-end on close container', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) - // Modal renders via portal + it('should have flex justify-end on close container', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const closeIcon = document.body.querySelector('svg.cursor-pointer') const closeContainer = closeIcon?.parentElement expect(closeContainer).toBeInTheDocument() @@ -176,9 +165,8 @@ describe('SecretKeyGenerateModal', () => { expect(closeContainer?.className).toContain('justify-end') }) - it('should have negative margin on close container', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) - // Modal renders via portal + it('should have negative margin on close container', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const closeIcon = document.body.querySelector('svg.cursor-pointer') const closeContainer = closeIcon?.parentElement expect(closeContainer).toBeInTheDocument() @@ -186,9 +174,8 @@ describe('SecretKeyGenerateModal', () => { expect(closeContainer?.className).toContain('-mt-6') }) - it('should have bottom margin on close container', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) - // Modal renders via portal + it('should have bottom margin on close container', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const closeIcon = document.body.querySelector('svg.cursor-pointer') const closeContainer = closeIcon?.parentElement expect(closeContainer).toBeInTheDocument() @@ -197,46 +184,45 @@ describe('SecretKeyGenerateModal', () => { }) describe('tips text styling', () => { - it('should have mt-1 margin on tips', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should have mt-1 margin on tips', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const tips = screen.getByText('appApi.apiKeyModal.generateTips') expect(tips.className).toContain('mt-1') }) - it('should have correct font size', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should have correct font size', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const tips = screen.getByText('appApi.apiKeyModal.generateTips') expect(tips.className).toContain('text-[13px]') }) - it('should have normal font weight', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should have normal font weight', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const tips = screen.getByText('appApi.apiKeyModal.generateTips') expect(tips.className).toContain('font-normal') }) - it('should have leading-5 line height', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should have leading-5 line height', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const tips = screen.getByText('appApi.apiKeyModal.generateTips') expect(tips.className).toContain('leading-5') }) - it('should have tertiary text color', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should have tertiary text color', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const tips = screen.getByText('appApi.apiKeyModal.generateTips') expect(tips.className).toContain('text-text-tertiary') }) }) describe('InputCopy section', () => { - it('should render InputCopy with token value', () => { - render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token')} />) + it('should render InputCopy with token value', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token')} />) expect(screen.getByText('test-token')).toBeInTheDocument() }) - it('should have w-full class on InputCopy', () => { - render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test')} />) - // The InputCopy component should have w-full + it('should have w-full class on InputCopy', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test')} />) const inputText = screen.getByText('test') const inputContainer = inputText.closest('.w-full') expect(inputContainer).toBeInTheDocument() @@ -244,58 +230,57 @@ describe('SecretKeyGenerateModal', () => { }) describe('OK button section', () => { - it('should render OK button', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should render OK button', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const button = screen.getByRole('button', { name: /ok/i }) expect(button).toBeInTheDocument() }) - it('should have button container with flex layout', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should have button container with flex layout', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const button = screen.getByRole('button', { name: /ok/i }) const container = button.parentElement expect(container).toBeInTheDocument() expect(container?.className).toContain('flex') }) - it('should have shrink-0 on button', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should have shrink-0 on button', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const button = screen.getByRole('button', { name: /ok/i }) expect(button.className).toContain('shrink-0') }) }) describe('button text styling', () => { - it('should have text-xs font size on button text', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should have text-xs font size on button text', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const buttonText = screen.getByText('appApi.actionMsg.ok') expect(buttonText.className).toContain('text-xs') }) - it('should have font-medium on button text', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should have font-medium on button text', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const buttonText = screen.getByText('appApi.actionMsg.ok') expect(buttonText.className).toContain('font-medium') }) - it('should have secondary text color on button text', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should have secondary text color on button text', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) const buttonText = screen.getByText('appApi.actionMsg.ok') expect(buttonText.className).toContain('text-text-secondary') }) }) describe('default prop values', () => { - it('should default isShow to false', () => { - // When isShow is explicitly set to false - render(<SecretKeyGenerateModal isShow={false} onClose={vi.fn()} />) + it('should default isShow to false', async () => { + await renderModal(<SecretKeyGenerateModal isShow={false} onClose={vi.fn()} />) expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument() }) }) describe('modal title', () => { - it('should display the correct title', () => { - render(<SecretKeyGenerateModal {...defaultProps} />) + it('should display the correct title', async () => { + await renderModal(<SecretKeyGenerateModal {...defaultProps} />) expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() }) }) diff --git a/web/app/components/develop/secret-key/secret-key-modal.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx similarity index 69% rename from web/app/components/develop/secret-key/secret-key-modal.spec.tsx rename to web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx index 79c51759ea..8cfd976a95 100644 --- a/web/app/components/develop/secret-key/secret-key-modal.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx @@ -1,8 +1,25 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import SecretKeyModal from './secret-key-modal' +import { afterEach } from 'vitest' +import SecretKeyModal from '../secret-key-modal' + +async function renderModal(ui: React.ReactElement) { + const result = render(ui) + await act(async () => { + vi.runAllTimers() + }) + return result +} + +async function flushTransitions() { + await act(async () => { + vi.runAllTimers() + }) + await act(async () => { + vi.runAllTimers() + }) +} -// Mock the app context const mockCurrentWorkspace = vi.fn().mockReturnValue({ id: 'workspace-1', name: 'Test Workspace', @@ -18,7 +35,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -// Mock the timestamp hook vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: vi.fn((value: number, _format: string) => `Formatted: ${value}`), @@ -26,7 +42,6 @@ vi.mock('@/hooks/use-timestamp', () => ({ }), })) -// Mock API services const mockCreateAppApikey = vi.fn().mockResolvedValue({ token: 'new-app-token-123' }) const mockDelAppApikey = vi.fn().mockResolvedValue({}) vi.mock('@/service/apps', () => ({ @@ -41,7 +56,6 @@ vi.mock('@/service/datasets', () => ({ delApikey: (...args: unknown[]) => mockDelDatasetApikey(...args), })) -// Mock React Query hooks for apps const mockAppApiKeysData = vi.fn().mockReturnValue({ data: [] }) const mockIsAppApiKeysLoading = vi.fn().mockReturnValue(false) const mockInvalidateAppApiKeys = vi.fn() @@ -54,7 +68,6 @@ vi.mock('@/service/use-apps', () => ({ useInvalidateAppApiKeys: () => mockInvalidateAppApiKeys, })) -// Mock React Query hooks for datasets const mockDatasetApiKeysData = vi.fn().mockReturnValue({ data: [] }) const mockIsDatasetApiKeysLoading = vi.fn().mockReturnValue(false) const mockInvalidateDatasetApiKeys = vi.fn() @@ -75,6 +88,7 @@ describe('SecretKeyModal', () => { beforeEach(() => { vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' }) mockIsCurrentWorkspaceManager.mockReturnValue(true) mockIsCurrentWorkspaceEditor.mockReturnValue(true) @@ -84,53 +98,57 @@ describe('SecretKeyModal', () => { mockIsDatasetApiKeysLoading.mockReturnValue(false) }) + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + describe('rendering when shown', () => { - it('should render the modal when isShow is true', () => { - render(<SecretKeyModal {...defaultProps} />) + it('should render the modal when isShow is true', async () => { + await renderModal(<SecretKeyModal {...defaultProps} />) expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() }) - it('should render the tips text', () => { - render(<SecretKeyModal {...defaultProps} />) + it('should render the tips text', async () => { + await renderModal(<SecretKeyModal {...defaultProps} />) expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument() }) - it('should render the create new key button', () => { - render(<SecretKeyModal {...defaultProps} />) + it('should render the create new key button', async () => { + await renderModal(<SecretKeyModal {...defaultProps} />) expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument() }) - it('should render the close icon', () => { - render(<SecretKeyModal {...defaultProps} />) - // Modal renders via portal, so we need to query from document.body + it('should render the close icon', async () => { + await renderModal(<SecretKeyModal {...defaultProps} />) const closeIcon = document.body.querySelector('svg.cursor-pointer') expect(closeIcon).toBeInTheDocument() }) }) describe('rendering when hidden', () => { - it('should not render content when isShow is false', () => { - render(<SecretKeyModal {...defaultProps} isShow={false} />) + it('should not render content when isShow is false', async () => { + await renderModal(<SecretKeyModal {...defaultProps} isShow={false} />) expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument() }) }) describe('loading state', () => { - it('should show loading when app API keys are loading', () => { + it('should show loading when app API keys are loading', async () => { mockIsAppApiKeysLoading.mockReturnValue(true) - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) expect(screen.getByRole('status')).toBeInTheDocument() }) - it('should show loading when dataset API keys are loading', () => { + it('should show loading when dataset API keys are loading', async () => { mockIsDatasetApiKeysLoading.mockReturnValue(true) - render(<SecretKeyModal {...defaultProps} />) + await renderModal(<SecretKeyModal {...defaultProps} />) expect(screen.getByRole('status')).toBeInTheDocument() }) - it('should not show loading when data is loaded', () => { + it('should not show loading when data is loaded', async () => { mockIsAppApiKeysLoading.mockReturnValue(false) - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) expect(screen.queryByRole('status')).not.toBeInTheDocument() }) }) @@ -145,49 +163,43 @@ describe('SecretKeyModal', () => { mockAppApiKeysData.mockReturnValue({ data: apiKeys }) }) - it('should render API keys when available', () => { - render(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Token 'sk-abc123def456ghi789' (21 chars) -> first 3 'sk-' + '...' + last 20 'k-abc123def456ghi789' + it('should render API keys when available', async () => { + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument() }) - it('should render created time for keys', () => { - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + it('should render created time for keys', async () => { + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) expect(screen.getByText('Formatted: 1700000000')).toBeInTheDocument() }) - it('should render last used time for keys', () => { - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + it('should render last used time for keys', async () => { + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) expect(screen.getByText('Formatted: 1700100000')).toBeInTheDocument() }) - it('should render "never" for keys without last_used_at', () => { - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + it('should render "never" for keys without last_used_at', async () => { + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) expect(screen.getByText('appApi.never')).toBeInTheDocument() }) - it('should render delete button for managers', () => { - render(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Delete button contains RiDeleteBinLine SVG - look for SVGs with h-4 w-4 class within buttons + it('should render delete button for managers', async () => { + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) const buttons = screen.getAllByRole('button') - // There should be at least 3 buttons: copy feedback, delete, and create expect(buttons.length).toBeGreaterThanOrEqual(2) - // Check for delete icon SVG - Modal renders via portal const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]') expect(deleteIcon).toBeInTheDocument() }) - it('should not render delete button for non-managers', () => { + it('should not render delete button for non-managers', async () => { mockIsCurrentWorkspaceManager.mockReturnValue(false) - render(<SecretKeyModal {...defaultProps} appId="app-123" />) - // The specific delete action button should not be present + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) const actionButtons = screen.getAllByRole('button') - // Should only have copy and create buttons, not delete expect(actionButtons.length).toBeGreaterThan(0) }) - it('should render table headers', () => { - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + it('should render table headers', async () => { + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) expect(screen.getByText('appApi.apiKeyModal.secretKey')).toBeInTheDocument() expect(screen.getByText('appApi.apiKeyModal.created')).toBeInTheDocument() expect(screen.getByText('appApi.apiKeyModal.lastUsed')).toBeInTheDocument() @@ -203,20 +215,18 @@ describe('SecretKeyModal', () => { mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys }) }) - it('should render dataset API keys when no appId', () => { - render(<SecretKeyModal {...defaultProps} />) - // Token 'dk-abc123def456ghi789' (21 chars) -> first 3 'dk-' + '...' + last 20 'k-abc123def456ghi789' + it('should render dataset API keys when no appId', async () => { + await renderModal(<SecretKeyModal {...defaultProps} />) expect(screen.getByText('dk-...k-abc123def456ghi789')).toBeInTheDocument() }) }) describe('close functionality', () => { it('should call onClose when X icon is clicked', async () => { - const user = userEvent.setup() + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) const onClose = vi.fn() - render(<SecretKeyModal {...defaultProps} onClose={onClose} />) + await renderModal(<SecretKeyModal {...defaultProps} onClose={onClose} />) - // Modal renders via portal, so we need to query from document.body const closeIcon = document.body.querySelector('svg.cursor-pointer') expect(closeIcon).toBeInTheDocument() @@ -224,14 +234,14 @@ describe('SecretKeyModal', () => { await user.click(closeIcon!) }) - expect(onClose).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalled() }) }) describe('create new key', () => { it('should call create API for app when button is clicked', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') await act(async () => { @@ -247,8 +257,8 @@ describe('SecretKeyModal', () => { }) it('should call create API for dataset when no appId', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} />) const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') await act(async () => { @@ -264,8 +274,8 @@ describe('SecretKeyModal', () => { }) it('should show generate modal after creating key', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') await act(async () => { @@ -273,14 +283,13 @@ describe('SecretKeyModal', () => { }) await waitFor(() => { - // The SecretKeyGenerateModal should be shown with the new token expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() }) }) it('should invalidate app API keys after creating', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') await act(async () => { @@ -293,8 +302,8 @@ describe('SecretKeyModal', () => { }) it('should invalidate dataset API keys after creating (no appId)', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} />) const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') await act(async () => { @@ -306,17 +315,17 @@ describe('SecretKeyModal', () => { }) }) - it('should disable create button when no workspace', () => { + it('should disable create button when no workspace', async () => { mockCurrentWorkspace.mockReturnValue(null) - render(<SecretKeyModal {...defaultProps} />) + await renderModal(<SecretKeyModal {...defaultProps} />) const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button') expect(createButton).toBeDisabled() }) - it('should disable create button when not editor', () => { + it('should disable create button when not editor', async () => { mockIsCurrentWorkspaceEditor.mockReturnValue(false) - render(<SecretKeyModal {...defaultProps} />) + await renderModal(<SecretKeyModal {...defaultProps} />) const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button') expect(createButton).toBeDisabled() @@ -332,80 +341,74 @@ describe('SecretKeyModal', () => { mockAppApiKeysData.mockReturnValue({ data: apiKeys }) }) - it('should render delete button for managers', () => { - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + it('should render delete button for managers', async () => { + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Find buttons that contain SVG (delete/copy buttons) const actionButtons = screen.getAllByRole('button') - // There should be at least copy, delete, and create buttons expect(actionButtons.length).toBeGreaterThanOrEqual(3) }) - it('should render API key row with actions', () => { - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + it('should render API key row with actions', async () => { + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Verify the truncated token is rendered expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument() }) - it('should have action buttons in the key row', () => { - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + it('should have action buttons in the key row', async () => { + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Check for action button containers - Modal renders via portal const actionContainers = document.body.querySelectorAll('[class*="space-x-2"]') expect(actionContainers.length).toBeGreaterThan(0) }) it('should have delete button visible for managers', async () => { - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Find the delete button by looking for the button with the delete icon const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]') const deleteButton = deleteIcon?.closest('button') expect(deleteButton).toBeInTheDocument() }) it('should show confirm dialog when delete button is clicked', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Find delete button by action-btn class (second action button after copy) const actionButtons = document.body.querySelectorAll('button.action-btn') - // The delete button is the second action button (first is copy) const deleteButton = actionButtons[1] expect(deleteButton).toBeInTheDocument() await act(async () => { await user.click(deleteButton!) + vi.runAllTimers() }) - // Confirm dialog should appear await waitFor(() => { expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() expect(screen.getByText('appApi.actionMsg.deleteConfirmTips')).toBeInTheDocument() }) + await flushTransitions() }) it('should call delete API for app when confirmed', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Find and click delete button const actionButtons = document.body.querySelectorAll('button.action-btn') const deleteButton = actionButtons[1] await act(async () => { await user.click(deleteButton!) + vi.runAllTimers() }) - // Wait for confirm dialog and click confirm await waitFor(() => { expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() }) + await flushTransitions() - // Find and click the confirm button const confirmButton = screen.getByText('common.operation.confirm') await act(async () => { await user.click(confirmButton) + vi.runAllTimers() }) await waitFor(() => { @@ -417,24 +420,25 @@ describe('SecretKeyModal', () => { }) it('should invalidate app API keys after deleting', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Find and click delete button const actionButtons = document.body.querySelectorAll('button.action-btn') const deleteButton = actionButtons[1] await act(async () => { await user.click(deleteButton!) + vi.runAllTimers() }) - // Wait for confirm dialog and click confirm await waitFor(() => { expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() }) + await flushTransitions() const confirmButton = screen.getByText('common.operation.confirm') await act(async () => { await user.click(confirmButton) + vi.runAllTimers() }) await waitFor(() => { @@ -443,33 +447,31 @@ describe('SecretKeyModal', () => { }) it('should close confirm dialog and clear delKeyId when cancel is clicked', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Find and click delete button const actionButtons = document.body.querySelectorAll('button.action-btn') const deleteButton = actionButtons[1] await act(async () => { await user.click(deleteButton!) + vi.runAllTimers() }) - // Wait for confirm dialog await waitFor(() => { expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() }) + await flushTransitions() - // Click cancel button const cancelButton = screen.getByText('common.operation.cancel') await act(async () => { await user.click(cancelButton) + vi.runAllTimers() }) - // Confirm dialog should close await waitFor(() => { expect(screen.queryByText('appApi.actionMsg.deleteConfirmTitle')).not.toBeInTheDocument() }) - // Delete API should not be called expect(mockDelAppApikey).not.toHaveBeenCalled() }) }) @@ -484,24 +486,25 @@ describe('SecretKeyModal', () => { }) it('should call delete API for dataset when no appId', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} />) - // Find and click delete button const actionButtons = document.body.querySelectorAll('button.action-btn') const deleteButton = actionButtons[1] await act(async () => { await user.click(deleteButton!) + vi.runAllTimers() }) - // Wait for confirm dialog and click confirm await waitFor(() => { expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() }) + await flushTransitions() const confirmButton = screen.getByText('common.operation.confirm') await act(async () => { await user.click(confirmButton) + vi.runAllTimers() }) await waitFor(() => { @@ -513,24 +516,25 @@ describe('SecretKeyModal', () => { }) it('should invalidate dataset API keys after deleting', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} />) - // Find and click delete button const actionButtons = document.body.querySelectorAll('button.action-btn') const deleteButton = actionButtons[1] await act(async () => { await user.click(deleteButton!) + vi.runAllTimers() }) - // Wait for confirm dialog and click confirm await waitFor(() => { expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() }) + await flushTransitions() const confirmButton = screen.getByText('common.operation.confirm') await act(async () => { await user.click(confirmButton) + vi.runAllTimers() }) await waitFor(() => { @@ -540,46 +544,42 @@ describe('SecretKeyModal', () => { }) describe('token truncation', () => { - it('should truncate token correctly', () => { + it('should truncate token correctly', async () => { const apiKeys = [ { id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null }, ] mockAppApiKeysData.mockReturnValue({ data: apiKeys }) - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Token format: first 3 chars + ... + last 20 chars - // 'sk-abcdefghijklmnopqrstuvwxyz1234567890' -> 'sk-...qrstuvwxyz1234567890' expect(screen.getByText('sk-...qrstuvwxyz1234567890')).toBeInTheDocument() }) }) describe('styling', () => { - it('should render modal with expected structure', () => { - render(<SecretKeyModal {...defaultProps} />) - // Modal should render and contain the title + it('should render modal with expected structure', async () => { + await renderModal(<SecretKeyModal {...defaultProps} />) expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() }) - it('should render create button with flex styling', () => { - render(<SecretKeyModal {...defaultProps} />) - // Modal renders via portal, so query from document.body + it('should render create button with flex styling', async () => { + await renderModal(<SecretKeyModal {...defaultProps} />) const flexContainers = document.body.querySelectorAll('[class*="flex"]') expect(flexContainers.length).toBeGreaterThan(0) }) }) describe('empty state', () => { - it('should not render table when no keys', () => { + it('should not render table when no keys', async () => { mockAppApiKeysData.mockReturnValue({ data: [] }) - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument() }) - it('should not render table when data is null', () => { + it('should not render table when data is null', async () => { mockAppApiKeysData.mockReturnValue(null) - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument() }) @@ -587,23 +587,23 @@ describe('SecretKeyModal', () => { describe('SecretKeyGenerateModal', () => { it('should close generate modal on close', async () => { - const user = userEvent.setup() - render(<SecretKeyModal {...defaultProps} appId="app-123" />) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />) - // Create a new key to open generate modal const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') await act(async () => { await user.click(createButton) + vi.runAllTimers() }) await waitFor(() => { expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() }) - // Find and click the close/OK button in generate modal const okButton = screen.getByText('appApi.actionMsg.ok') await act(async () => { await user.click(okButton) + vi.runAllTimers() }) await waitFor(() => { diff --git a/web/app/components/goto-anything/command-selector.spec.tsx b/web/app/components/goto-anything/__tests__/command-selector.spec.tsx similarity index 96% rename from web/app/components/goto-anything/command-selector.spec.tsx rename to web/app/components/goto-anything/__tests__/command-selector.spec.tsx index 0712a1afd6..56e40a71f0 100644 --- a/web/app/components/goto-anything/command-selector.spec.tsx +++ b/web/app/components/goto-anything/__tests__/command-selector.spec.tsx @@ -1,9 +1,9 @@ -import type { ActionItem } from './actions/types' +import type { ActionItem } from '../actions/types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Command } from 'cmdk' import * as React from 'react' -import CommandSelector from './command-selector' +import CommandSelector from '../command-selector' vi.mock('next/navigation', () => ({ usePathname: () => '/app', @@ -16,7 +16,7 @@ const slashCommandsMock = [{ isAvailable: () => true, }] -vi.mock('./actions/commands/registry', () => ({ +vi.mock('../actions/commands/registry', () => ({ slashCommandRegistry: { getAvailableCommands: () => slashCommandsMock, }, @@ -97,7 +97,6 @@ describe('CommandSelector', () => { </Command>, ) - // Should show the zen command from mock expect(screen.getByText('/zen')).toBeInTheDocument() }) @@ -125,7 +124,6 @@ describe('CommandSelector', () => { </Command>, ) - // Should show @ commands but not / expect(screen.getByText('@app')).toBeInTheDocument() expect(screen.queryByText('/')).not.toBeInTheDocument() }) diff --git a/web/app/components/goto-anything/context.spec.tsx b/web/app/components/goto-anything/__tests__/context.spec.tsx similarity index 96% rename from web/app/components/goto-anything/context.spec.tsx rename to web/app/components/goto-anything/__tests__/context.spec.tsx index 2be2cbc730..c427f76c61 100644 --- a/web/app/components/goto-anything/context.spec.tsx +++ b/web/app/components/goto-anything/__tests__/context.spec.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import { GotoAnythingProvider, useGotoAnythingContext } from './context' +import { GotoAnythingProvider, useGotoAnythingContext } from '../context' let pathnameMock: string | null | undefined = '/' vi.mock('next/navigation', () => ({ @@ -8,7 +8,7 @@ vi.mock('next/navigation', () => ({ })) let isWorkflowPageMock = false -vi.mock('../workflow/constants', () => ({ +vi.mock('../../workflow/constants', () => ({ isInWorkflowPage: () => isWorkflowPageMock, })) diff --git a/web/app/components/goto-anything/index.spec.tsx b/web/app/components/goto-anything/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/goto-anything/index.spec.tsx rename to web/app/components/goto-anything/__tests__/index.spec.tsx index 6a6143a6e2..eb5fa8ccdd 100644 --- a/web/app/components/goto-anything/index.spec.tsx +++ b/web/app/components/goto-anything/__tests__/index.spec.tsx @@ -1,27 +1,15 @@ import type { ReactNode } from 'react' -import type { ActionItem, SearchResult } from './actions/types' +import type { ActionItem, SearchResult } from '../actions/types' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import GotoAnything from './index' +import GotoAnything from '../index' -// Test helper type that matches SearchResult but allows ReactNode for icon and flexible data type TestSearchResult = Omit<SearchResult, 'icon' | 'data'> & { icon?: ReactNode data?: Record<string, unknown> } -// Mock react-i18next to return namespace.key format -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const ns = options?.ns || 'common' - return `${ns}.${key}` - }, - i18n: { language: 'en' }, - }), -})) - const routerPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -65,7 +53,7 @@ vi.mock('@/context/i18n', () => ({ })) const contextValue = { isWorkflowPage: false, isRagPipelinePage: false } -vi.mock('./context', () => ({ +vi.mock('../context', () => ({ useGotoAnythingContext: () => contextValue, GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, })) @@ -93,13 +81,13 @@ const createActionsMock = vi.fn(() => actionsMock) const matchActionMock = vi.fn(() => undefined) const searchAnythingMock = vi.fn(async () => mockQueryResult.data) -vi.mock('./actions', () => ({ +vi.mock('../actions', () => ({ createActions: () => createActionsMock(), matchAction: () => matchActionMock(), searchAnything: () => searchAnythingMock(), })) -vi.mock('./actions/commands', () => ({ +vi.mock('../actions/commands', () => ({ SlashCommandProvider: () => null, })) @@ -110,7 +98,7 @@ type MockSlashCommand = { } | null let mockFindCommand: MockSlashCommand = null -vi.mock('./actions/commands/registry', () => ({ +vi.mock('../actions/commands/registry', () => ({ slashCommandRegistry: { findCommand: () => mockFindCommand, getAvailableCommands: () => [], @@ -129,7 +117,7 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({ selectWorkflowNode: vi.fn(), })) -vi.mock('../plugins/install-plugin/install-from-marketplace', () => ({ +vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({ default: (props: { manifest?: { name?: string }, onClose: () => void, onSuccess: () => void }) => ( <div data-testid="install-modal"> <span>{props.manifest?.name}</span> @@ -207,23 +195,19 @@ describe('GotoAnything', () => { const user = userEvent.setup() render(<GotoAnything />) - // Open modal first time triggerKeyPress('ctrl.k') await waitFor(() => { expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument() }) - // Type something const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') await user.type(input, 'test') - // Close modal triggerKeyPress('esc') await waitFor(() => { expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument() }) - // Open modal again - should be empty triggerKeyPress('ctrl.k') await waitFor(() => { const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') @@ -278,7 +262,6 @@ describe('GotoAnything', () => { const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') await user.type(input, 'test query') - // Should not throw and input should have value expect(input).toHaveValue('test query') }) }) @@ -303,7 +286,6 @@ describe('GotoAnything', () => { const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') await user.type(input, 'search') - // Loading state shows in both EmptyState (spinner) and Footer const searchingTexts = screen.getAllByText('app.gotoAnything.searching') expect(searchingTexts.length).toBeGreaterThanOrEqual(1) }) diff --git a/web/app/components/goto-anything/actions/__tests__/app.spec.ts b/web/app/components/goto-anything/actions/__tests__/app.spec.ts new file mode 100644 index 0000000000..2a09b8be1d --- /dev/null +++ b/web/app/components/goto-anything/actions/__tests__/app.spec.ts @@ -0,0 +1,71 @@ +import type { App } from '@/types/app' +import { appAction } from '../app' + +vi.mock('@/service/apps', () => ({ + fetchAppList: vi.fn(), +})) + +vi.mock('@/utils/app-redirection', () => ({ + getRedirectionPath: vi.fn((_isAdmin: boolean, app: { id: string }) => `/app/${app.id}`), +})) + +vi.mock('../../../app/type-selector', () => ({ + AppTypeIcon: () => null, +})) + +vi.mock('../../../base/app-icon', () => ({ + default: () => null, +})) + +describe('appAction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('has correct metadata', () => { + expect(appAction.key).toBe('@app') + expect(appAction.shortcut).toBe('@app') + }) + + it('returns parsed app results on success', async () => { + const { fetchAppList } = await import('@/service/apps') + vi.mocked(fetchAppList).mockResolvedValue({ + data: [ + { id: 'app-1', name: 'My App', description: 'A great app', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, + ], + has_more: false, + limit: 10, + page: 1, + total: 1, + }) + + const results = await appAction.search('@app test', 'test', 'en') + + expect(fetchAppList).toHaveBeenCalledWith({ + url: 'apps', + params: { page: 1, name: 'test' }, + }) + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + id: 'app-1', + title: 'My App', + type: 'app', + }) + }) + + it('returns empty array when response has no data', async () => { + const { fetchAppList } = await import('@/service/apps') + vi.mocked(fetchAppList).mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 }) + + const results = await appAction.search('@app', '', 'en') + expect(results).toEqual([]) + }) + + it('returns empty array on API failure', async () => { + const { fetchAppList } = await import('@/service/apps') + vi.mocked(fetchAppList).mockRejectedValue(new Error('network error')) + + const results = await appAction.search('@app fail', 'fail', 'en') + expect(results).toEqual([]) + }) +}) diff --git a/web/app/components/goto-anything/actions/__tests__/index.spec.ts b/web/app/components/goto-anything/actions/__tests__/index.spec.ts new file mode 100644 index 0000000000..8b92297a57 --- /dev/null +++ b/web/app/components/goto-anything/actions/__tests__/index.spec.ts @@ -0,0 +1,276 @@ +import type { ActionItem, SearchResult } from '../types' +import type { DataSet } from '@/models/datasets' +import type { App } from '@/types/app' +import { slashCommandRegistry } from '../commands/registry' +import { createActions, matchAction, searchAnything } from '../index' + +vi.mock('../app', () => ({ + appAction: { + key: '@app', + shortcut: '@app', + title: 'Apps', + description: 'Search apps', + search: vi.fn().mockResolvedValue([]), + } satisfies ActionItem, +})) + +vi.mock('../knowledge', () => ({ + knowledgeAction: { + key: '@knowledge', + shortcut: '@kb', + title: 'Knowledge', + description: 'Search knowledge', + search: vi.fn().mockResolvedValue([]), + } satisfies ActionItem, +})) + +vi.mock('../plugin', () => ({ + pluginAction: { + key: '@plugin', + shortcut: '@plugin', + title: 'Plugins', + description: 'Search plugins', + search: vi.fn().mockResolvedValue([]), + } satisfies ActionItem, +})) + +vi.mock('../commands', () => ({ + slashAction: { + key: '/', + shortcut: '/', + title: 'Commands', + description: 'Slash commands', + search: vi.fn().mockResolvedValue([]), + } satisfies ActionItem, +})) + +vi.mock('../workflow-nodes', () => ({ + workflowNodesAction: { + key: '@node', + shortcut: '@node', + title: 'Workflow Nodes', + description: 'Search workflow nodes', + search: vi.fn().mockResolvedValue([]), + } satisfies ActionItem, +})) + +vi.mock('../rag-pipeline-nodes', () => ({ + ragPipelineNodesAction: { + key: '@node', + shortcut: '@node', + title: 'RAG Pipeline Nodes', + description: 'Search RAG nodes', + search: vi.fn().mockResolvedValue([]), + } satisfies ActionItem, +})) + +vi.mock('../commands/registry') + +describe('createActions', () => { + it('returns base actions when neither workflow nor rag-pipeline page', () => { + const actions = createActions(false, false) + + expect(actions).toHaveProperty('slash') + expect(actions).toHaveProperty('app') + expect(actions).toHaveProperty('knowledge') + expect(actions).toHaveProperty('plugin') + expect(actions).not.toHaveProperty('node') + }) + + it('includes workflow nodes action on workflow pages', () => { + const actions = createActions(true, false) as Record<string, ActionItem> + + expect(actions).toHaveProperty('node') + expect(actions.node.title).toBe('Workflow Nodes') + }) + + it('includes rag-pipeline nodes action on rag-pipeline pages', () => { + const actions = createActions(false, true) as Record<string, ActionItem> + + expect(actions).toHaveProperty('node') + expect(actions.node.title).toBe('RAG Pipeline Nodes') + }) + + it('rag-pipeline page takes priority over workflow page', () => { + const actions = createActions(true, true) as Record<string, ActionItem> + + expect(actions.node.title).toBe('RAG Pipeline Nodes') + }) +}) + +describe('searchAnything', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('delegates to specific action when actionItem is provided', async () => { + const mockResults: SearchResult[] = [ + { id: '1', title: 'App1', type: 'app', data: {} as unknown as App }, + ] + const action: ActionItem = { + key: '@app', + shortcut: '@app', + title: 'Apps', + description: 'Search apps', + search: vi.fn().mockResolvedValue(mockResults), + } + + const results = await searchAnything('en', '@app myquery', action) + + expect(action.search).toHaveBeenCalledWith('@app myquery', 'myquery', 'en') + expect(results).toEqual(mockResults) + }) + + it('strips action prefix from search term', async () => { + const action: ActionItem = { + key: '@knowledge', + shortcut: '@kb', + title: 'KB', + description: 'Search KB', + search: vi.fn().mockResolvedValue([]), + } + + await searchAnything('en', '@kb hello', action) + + expect(action.search).toHaveBeenCalledWith('@kb hello', 'hello', 'en') + }) + + it('returns empty for queries starting with @ without actionItem', async () => { + const results = await searchAnything('en', '@unknown') + expect(results).toEqual([]) + }) + + it('returns empty for queries starting with / without actionItem', async () => { + const results = await searchAnything('en', '/theme') + expect(results).toEqual([]) + }) + + it('handles action search failure gracefully', async () => { + const action: ActionItem = { + key: '@app', + shortcut: '@app', + title: 'Apps', + description: 'Search apps', + search: vi.fn().mockRejectedValue(new Error('network error')), + } + + const results = await searchAnything('en', '@app test', action) + expect(results).toEqual([]) + }) + + it('runs global search across all non-slash actions for plain queries', async () => { + const appResults: SearchResult[] = [ + { id: 'a1', title: 'My App', type: 'app', data: {} as unknown as App }, + ] + const kbResults: SearchResult[] = [ + { id: 'k1', title: 'My KB', type: 'knowledge', data: {} as unknown as DataSet }, + ] + + const dynamicActions: Record<string, ActionItem> = { + slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn().mockResolvedValue([]) }, + app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockResolvedValue(appResults) }, + knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', search: vi.fn().mockResolvedValue(kbResults) }, + } + + const results = await searchAnything('en', 'my query', undefined, dynamicActions) + + expect(dynamicActions.slash.search).not.toHaveBeenCalled() + expect(results).toHaveLength(2) + expect(results).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1' }), + expect.objectContaining({ id: 'k1' }), + ])) + }) + + it('handles partial search failures in global search gracefully', async () => { + const dynamicActions: Record<string, ActionItem> = { + app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockRejectedValue(new Error('fail')) }, + knowledge: { + key: '@knowledge', + shortcut: '@kb', + title: 'KB', + description: '', + search: vi.fn().mockResolvedValue([ + { id: 'k1', title: 'KB1', type: 'knowledge', data: {} as unknown as DataSet }, + ]), + }, + } + + const results = await searchAnything('en', 'query', undefined, dynamicActions) + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('k1') + }) +}) + +describe('matchAction', () => { + const actions: Record<string, ActionItem> = { + app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn() }, + knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', search: vi.fn() }, + plugin: { key: '@plugin', shortcut: '@plugin', title: 'Plugin', description: '', search: vi.fn() }, + slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn() }, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('matches @app query', () => { + const result = matchAction('@app test', actions) + expect(result?.key).toBe('@app') + }) + + it('matches @kb shortcut', () => { + const result = matchAction('@kb test', actions) + expect(result?.key).toBe('@knowledge') + }) + + it('matches @plugin query', () => { + const result = matchAction('@plugin test', actions) + expect(result?.key).toBe('@plugin') + }) + + it('returns undefined for unmatched query', () => { + vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([]) + const result = matchAction('random query', actions) + expect(result).toBeUndefined() + }) + + describe('slash command matching', () => { + it('matches submenu command with full name', () => { + vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ + { name: 'theme', mode: 'submenu', description: '', search: vi.fn() }, + ]) + + const result = matchAction('/theme', actions) + expect(result?.key).toBe('/') + }) + + it('matches submenu command with args', () => { + vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ + { name: 'theme', mode: 'submenu', description: '', search: vi.fn() }, + ]) + + const result = matchAction('/theme dark', actions) + expect(result?.key).toBe('/') + }) + + it('does not match direct-mode commands', () => { + vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ + { name: 'docs', mode: 'direct', description: '', search: vi.fn() }, + ]) + + const result = matchAction('/docs', actions) + expect(result).toBeUndefined() + }) + + it('does not match partial slash command name', () => { + vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ + { name: 'theme', mode: 'submenu', description: '', search: vi.fn() }, + ]) + + const result = matchAction('/the', actions) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts b/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts new file mode 100644 index 0000000000..cb39bea0e5 --- /dev/null +++ b/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts @@ -0,0 +1,93 @@ +import type { DataSet } from '@/models/datasets' +import { knowledgeAction } from '../knowledge' + +vi.mock('@/service/datasets', () => ({ + fetchDatasets: vi.fn(), +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: string[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('../../../base/icons/src/vender/solid/files', () => ({ + Folder: () => null, +})) + +describe('knowledgeAction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('has correct metadata', () => { + expect(knowledgeAction.key).toBe('@knowledge') + expect(knowledgeAction.shortcut).toBe('@kb') + }) + + it('returns parsed dataset results on success', async () => { + const { fetchDatasets } = await import('@/service/datasets') + vi.mocked(fetchDatasets).mockResolvedValue({ + data: [ + { id: 'ds-1', name: 'My Knowledge', description: 'A KB', provider: 'vendor', embedding_available: true } as unknown as DataSet, + ], + has_more: false, + limit: 10, + page: 1, + total: 1, + }) + + const results = await knowledgeAction.search('@knowledge query', 'query', 'en') + + expect(fetchDatasets).toHaveBeenCalledWith({ + url: '/datasets', + params: { page: 1, limit: 10, keyword: 'query' }, + }) + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + id: 'ds-1', + title: 'My Knowledge', + type: 'knowledge', + }) + }) + + it('generates correct path for external provider', async () => { + const { fetchDatasets } = await import('@/service/datasets') + vi.mocked(fetchDatasets).mockResolvedValue({ + data: [ + { id: 'ds-ext', name: 'External', description: '', provider: 'external', embedding_available: true } as unknown as DataSet, + ], + has_more: false, + limit: 10, + page: 1, + total: 1, + }) + + const results = await knowledgeAction.search('@knowledge', '', 'en') + + expect(results[0].path).toBe('/datasets/ds-ext/hitTesting') + }) + + it('generates correct path for non-external provider', async () => { + const { fetchDatasets } = await import('@/service/datasets') + vi.mocked(fetchDatasets).mockResolvedValue({ + data: [ + { id: 'ds-2', name: 'Internal', description: '', provider: 'vendor', embedding_available: true } as unknown as DataSet, + ], + has_more: false, + limit: 10, + page: 1, + total: 1, + }) + + const results = await knowledgeAction.search('@knowledge', '', 'en') + + expect(results[0].path).toBe('/datasets/ds-2/documents') + }) + + it('returns empty array on API failure', async () => { + const { fetchDatasets } = await import('@/service/datasets') + vi.mocked(fetchDatasets).mockRejectedValue(new Error('fail')) + + const results = await knowledgeAction.search('@knowledge', 'fail', 'en') + expect(results).toEqual([]) + }) +}) diff --git a/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts b/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts new file mode 100644 index 0000000000..a5d8fe444c --- /dev/null +++ b/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts @@ -0,0 +1,72 @@ +import { pluginAction } from '../plugin' + +vi.mock('@/service/base', () => ({ + postMarketplace: vi.fn(), +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: vi.fn((obj: Record<string, string> | string, locale: string) => { + if (typeof obj === 'string') + return obj + return obj[locale] || obj.en_US || '' + }), +})) + +vi.mock('../../../plugins/card/base/card-icon', () => ({ + default: () => null, +})) + +vi.mock('../../../plugins/marketplace/utils', () => ({ + getPluginIconInMarketplace: vi.fn(() => 'icon-url'), +})) + +describe('pluginAction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('has correct metadata', () => { + expect(pluginAction.key).toBe('@plugin') + expect(pluginAction.shortcut).toBe('@plugin') + }) + + it('returns parsed plugin results on success', async () => { + const { postMarketplace } = await import('@/service/base') + vi.mocked(postMarketplace).mockResolvedValue({ + data: { + plugins: [ + { name: 'plugin-1', label: { en_US: 'My Plugin' }, brief: { en_US: 'A plugin' }, icon: 'icon.png' }, + ], + total: 1, + }, + }) + + const results = await pluginAction.search('@plugin', 'test', 'en_US') + + expect(postMarketplace).toHaveBeenCalledWith('/plugins/search/advanced', { + body: { page: 1, page_size: 10, query: 'test', type: 'plugin' }, + }) + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + id: 'plugin-1', + title: 'My Plugin', + type: 'plugin', + }) + }) + + it('returns empty array when response has unexpected structure', async () => { + const { postMarketplace } = await import('@/service/base') + vi.mocked(postMarketplace).mockResolvedValue({ data: {} }) + + const results = await pluginAction.search('@plugin', 'test', 'en') + expect(results).toEqual([]) + }) + + it('returns empty array on API failure', async () => { + const { postMarketplace } = await import('@/service/base') + vi.mocked(postMarketplace).mockRejectedValue(new Error('fail')) + + const results = await pluginAction.search('@plugin', 'fail', 'en') + expect(results).toEqual([]) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts new file mode 100644 index 0000000000..559e7e1821 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts @@ -0,0 +1,68 @@ +import { executeCommand, registerCommands, unregisterCommands } from '../command-bus' + +describe('command-bus', () => { + afterEach(() => { + unregisterCommands(['test.a', 'test.b', 'test.c', 'async.cmd', 'noop']) + }) + + describe('registerCommands / executeCommand', () => { + it('registers and executes a sync command', async () => { + const handler = vi.fn() + registerCommands({ 'test.a': handler }) + + await executeCommand('test.a', { value: 42 }) + + expect(handler).toHaveBeenCalledWith({ value: 42 }) + }) + + it('registers and executes an async command', async () => { + const handler = vi.fn().mockResolvedValue(undefined) + registerCommands({ 'async.cmd': handler }) + + await executeCommand('async.cmd') + + expect(handler).toHaveBeenCalled() + }) + + it('registers multiple commands at once', async () => { + const handlerA = vi.fn() + const handlerB = vi.fn() + registerCommands({ 'test.a': handlerA, 'test.b': handlerB }) + + await executeCommand('test.a') + await executeCommand('test.b') + + expect(handlerA).toHaveBeenCalled() + expect(handlerB).toHaveBeenCalled() + }) + + it('silently ignores unregistered command names', async () => { + await expect(executeCommand('nonexistent')).resolves.toBeUndefined() + }) + + it('passes undefined args when not provided', async () => { + const handler = vi.fn() + registerCommands({ 'test.c': handler }) + + await executeCommand('test.c') + + expect(handler).toHaveBeenCalledWith(undefined) + }) + }) + + describe('unregisterCommands', () => { + it('removes commands so they can no longer execute', async () => { + const handler = vi.fn() + registerCommands({ 'test.a': handler }) + + unregisterCommands(['test.a']) + await executeCommand('test.a') + + expect(handler).not.toHaveBeenCalled() + }) + + it('handles unregistering non-existent commands gracefully', () => { + expect(() => unregisterCommands(['nope'])).not.toThrow() + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts new file mode 100644 index 0000000000..1366c27245 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts @@ -0,0 +1,212 @@ +/** + * Tests for direct-mode commands that share similar patterns: + * docs, account, community, forum + * + * Each command: opens a URL or navigates, has direct mode, and registers a navigation command. + */ +import { accountCommand } from '../account' +import { registerCommands, unregisterCommands } from '../command-bus' +import { communityCommand } from '../community' +import { docsCommand } from '../docs' +import { forumCommand } from '../forum' + +vi.mock('../command-bus') + +vi.mock('react-i18next', () => ({ + getI18n: () => ({ + t: (key: string) => key, + language: 'en', + }), +})) + +vi.mock('@/context/i18n', () => ({ + defaultDocBaseUrl: 'https://docs.dify.ai', +})) + +vi.mock('@/i18n-config/language', () => ({ + getDocLanguage: (locale: string) => locale === 'en' ? 'en' : locale, +})) + +describe('docsCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('has correct metadata', () => { + expect(docsCommand.name).toBe('docs') + expect(docsCommand.mode).toBe('direct') + expect(docsCommand.execute).toBeDefined() + }) + + it('execute opens documentation in new tab', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + docsCommand.execute?.() + + expect(openSpy).toHaveBeenCalledWith( + expect.stringContaining('https://docs.dify.ai'), + '_blank', + 'noopener,noreferrer', + ) + openSpy.mockRestore() + }) + + it('search returns a single doc result', async () => { + const results = await docsCommand.search('', 'en') + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + id: 'doc', + type: 'command', + data: { command: 'navigation.doc', args: {} }, + }) + }) + + it('registers navigation.doc command', () => { + docsCommand.register?.({} as Record<string, never>) + expect(registerCommands).toHaveBeenCalledWith({ 'navigation.doc': expect.any(Function) }) + }) + + it('unregisters navigation.doc command', () => { + docsCommand.unregister?.() + expect(unregisterCommands).toHaveBeenCalledWith(['navigation.doc']) + }) +}) + +describe('accountCommand', () => { + let originalHref: string + + beforeEach(() => { + vi.clearAllMocks() + originalHref = window.location.href + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true }) + }) + + it('has correct metadata', () => { + expect(accountCommand.name).toBe('account') + expect(accountCommand.mode).toBe('direct') + expect(accountCommand.execute).toBeDefined() + }) + + it('execute navigates to /account', () => { + Object.defineProperty(window, 'location', { value: { href: '' }, writable: true }) + accountCommand.execute?.() + expect(window.location.href).toBe('/account') + }) + + it('search returns account result', async () => { + const results = await accountCommand.search('', 'en') + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + id: 'account', + type: 'command', + data: { command: 'navigation.account', args: {} }, + }) + }) + + it('registers navigation.account command', () => { + accountCommand.register?.({} as Record<string, never>) + expect(registerCommands).toHaveBeenCalledWith({ 'navigation.account': expect.any(Function) }) + }) + + it('unregisters navigation.account command', () => { + accountCommand.unregister?.() + expect(unregisterCommands).toHaveBeenCalledWith(['navigation.account']) + }) +}) + +describe('communityCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('has correct metadata', () => { + expect(communityCommand.name).toBe('community') + expect(communityCommand.mode).toBe('direct') + expect(communityCommand.execute).toBeDefined() + }) + + it('execute opens Discord URL', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + communityCommand.execute?.() + + expect(openSpy).toHaveBeenCalledWith( + 'https://discord.gg/5AEfbxcd9k', + '_blank', + 'noopener,noreferrer', + ) + openSpy.mockRestore() + }) + + it('search returns community result', async () => { + const results = await communityCommand.search('', 'en') + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + id: 'community', + type: 'command', + data: { command: 'navigation.community' }, + }) + }) + + it('registers navigation.community command', () => { + communityCommand.register?.({} as Record<string, never>) + expect(registerCommands).toHaveBeenCalledWith({ 'navigation.community': expect.any(Function) }) + }) + + it('unregisters navigation.community command', () => { + communityCommand.unregister?.() + expect(unregisterCommands).toHaveBeenCalledWith(['navigation.community']) + }) +}) + +describe('forumCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('has correct metadata', () => { + expect(forumCommand.name).toBe('forum') + expect(forumCommand.mode).toBe('direct') + expect(forumCommand.execute).toBeDefined() + }) + + it('execute opens forum URL', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + forumCommand.execute?.() + + expect(openSpy).toHaveBeenCalledWith( + 'https://forum.dify.ai', + '_blank', + 'noopener,noreferrer', + ) + openSpy.mockRestore() + }) + + it('search returns forum result', async () => { + const results = await forumCommand.search('', 'en') + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + id: 'forum', + type: 'command', + data: { command: 'navigation.forum' }, + }) + }) + + it('registers navigation.forum command', () => { + forumCommand.register?.({} as Record<string, never>) + expect(registerCommands).toHaveBeenCalledWith({ 'navigation.forum': expect.any(Function) }) + }) + + it('unregisters navigation.forum command', () => { + forumCommand.unregister?.() + expect(unregisterCommands).toHaveBeenCalledWith(['navigation.forum']) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts new file mode 100644 index 0000000000..54aa28d24a --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts @@ -0,0 +1,89 @@ +import { registerCommands, unregisterCommands } from '../command-bus' +import { languageCommand } from '../language' + +vi.mock('../command-bus') + +vi.mock('react-i18next', () => ({ + getI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/i18n-config/language', () => ({ + languages: [ + { value: 'en-US', name: 'English', supported: true }, + { value: 'zh-Hans', name: 'çź€äœ“äž­æ–‡', supported: true }, + { value: 'ja-JP', name: 'æ—„æœŹèȘž', supported: true }, + { value: 'unsupported', name: 'Unsupported', supported: false }, + ], +})) + +describe('languageCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('has correct metadata', () => { + expect(languageCommand.name).toBe('language') + expect(languageCommand.aliases).toEqual(['lang']) + expect(languageCommand.mode).toBe('submenu') + expect(languageCommand.execute).toBeUndefined() + }) + + describe('search', () => { + it('returns all supported languages when query is empty', async () => { + const results = await languageCommand.search('', 'en') + + expect(results).toHaveLength(3) // 3 supported languages + expect(results.every(r => r.type === 'command')).toBe(true) + }) + + it('filters languages by name query', async () => { + const results = await languageCommand.search('english', 'en') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('lang-en-US') + }) + + it('filters languages by value query', async () => { + const results = await languageCommand.search('zh', 'en') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('lang-zh-Hans') + }) + + it('returns command data with i18n.set command', async () => { + const results = await languageCommand.search('', 'en') + + results.forEach((r) => { + expect(r.data.command).toBe('i18n.set') + expect(r.data.args).toHaveProperty('locale') + }) + }) + }) + + describe('register / unregister', () => { + it('registers i18n.set command', () => { + languageCommand.register?.({ setLocale: vi.fn() }) + + expect(registerCommands).toHaveBeenCalledWith({ 'i18n.set': expect.any(Function) }) + }) + + it('unregisters i18n.set command', () => { + languageCommand.unregister?.() + + expect(unregisterCommands).toHaveBeenCalledWith(['i18n.set']) + }) + + it('registered handler calls setLocale with correct locale', async () => { + const setLocale = vi.fn().mockResolvedValue(undefined) + vi.mocked(registerCommands).mockImplementation((map) => { + map['i18n.set']?.({ locale: 'zh-Hans' }) + }) + + languageCommand.register?.({ setLocale }) + + expect(setLocale).toHaveBeenCalledWith('zh-Hans') + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts new file mode 100644 index 0000000000..2488ffed28 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts @@ -0,0 +1,267 @@ +import type { SlashCommandHandler } from '../types' +import { SlashCommandRegistry } from '../registry' + +function createHandler(overrides: Partial<SlashCommandHandler> = {}): SlashCommandHandler { + return { + name: 'test', + description: 'Test command', + search: vi.fn().mockResolvedValue([]), + register: vi.fn(), + unregister: vi.fn(), + ...overrides, + } +} + +describe('SlashCommandRegistry', () => { + let registry: SlashCommandRegistry + + beforeEach(() => { + registry = new SlashCommandRegistry() + }) + + describe('register & findCommand', () => { + it('registers a handler and retrieves it by name', () => { + const handler = createHandler({ name: 'docs' }) + registry.register(handler) + + expect(registry.findCommand('docs')).toBe(handler) + }) + + it('registers aliases so handler is found by any alias', () => { + const handler = createHandler({ name: 'language', aliases: ['lang', 'l'] }) + registry.register(handler) + + expect(registry.findCommand('language')).toBe(handler) + expect(registry.findCommand('lang')).toBe(handler) + expect(registry.findCommand('l')).toBe(handler) + }) + + it('calls handler.register with provided deps', () => { + const handler = createHandler({ name: 'theme' }) + const deps = { setTheme: vi.fn() } + registry.register(handler, deps) + + expect(handler.register).toHaveBeenCalledWith(deps) + }) + + it('does not call handler.register when no deps provided', () => { + const handler = createHandler({ name: 'docs' }) + registry.register(handler) + + expect(handler.register).not.toHaveBeenCalled() + }) + + it('returns undefined for unknown command name', () => { + expect(registry.findCommand('nonexistent')).toBeUndefined() + }) + }) + + describe('unregister', () => { + it('removes handler by name', () => { + const handler = createHandler({ name: 'docs' }) + registry.register(handler) + registry.unregister('docs') + + expect(registry.findCommand('docs')).toBeUndefined() + }) + + it('removes all aliases', () => { + const handler = createHandler({ name: 'language', aliases: ['lang'] }) + registry.register(handler) + registry.unregister('language') + + expect(registry.findCommand('language')).toBeUndefined() + expect(registry.findCommand('lang')).toBeUndefined() + }) + + it('calls handler.unregister', () => { + const handler = createHandler({ name: 'docs' }) + registry.register(handler) + registry.unregister('docs') + + expect(handler.unregister).toHaveBeenCalled() + }) + + it('is a no-op for unknown command', () => { + expect(() => registry.unregister('unknown')).not.toThrow() + }) + }) + + describe('getAllCommands', () => { + it('returns deduplicated handlers', () => { + const h1 = createHandler({ name: 'theme', aliases: ['t'] }) + const h2 = createHandler({ name: 'docs' }) + registry.register(h1) + registry.register(h2) + + const commands = registry.getAllCommands() + expect(commands).toHaveLength(2) + expect(commands).toContainEqual(expect.objectContaining({ name: 'theme' })) + expect(commands).toContainEqual(expect.objectContaining({ name: 'docs' })) + }) + + it('returns empty array when nothing registered', () => { + expect(registry.getAllCommands()).toEqual([]) + }) + }) + + describe('getAvailableCommands', () => { + it('includes commands without isAvailable guard', () => { + registry.register(createHandler({ name: 'docs' })) + + expect(registry.getAvailableCommands()).toHaveLength(1) + }) + + it('includes commands where isAvailable returns true', () => { + registry.register(createHandler({ name: 'zen', isAvailable: () => true })) + + expect(registry.getAvailableCommands()).toHaveLength(1) + }) + + it('excludes commands where isAvailable returns false', () => { + registry.register(createHandler({ name: 'zen', isAvailable: () => false })) + + expect(registry.getAvailableCommands()).toHaveLength(0) + }) + }) + + describe('search', () => { + it('returns root commands for "/"', async () => { + registry.register(createHandler({ name: 'theme', description: 'Change theme' })) + registry.register(createHandler({ name: 'docs', description: 'Open docs' })) + + const results = await registry.search('/') + + expect(results).toHaveLength(2) + expect(results[0]).toMatchObject({ + id: expect.stringContaining('root-'), + type: 'command', + }) + }) + + it('returns root commands for "/ "', async () => { + registry.register(createHandler({ name: 'theme' })) + + const results = await registry.search('/ ') + expect(results).toHaveLength(1) + }) + + it('delegates to exact-match handler for "/theme dark"', async () => { + const mockResults = [{ id: 'dark', title: 'Dark', description: '', type: 'command' as const, data: {} }] + const handler = createHandler({ + name: 'theme', + search: vi.fn().mockResolvedValue(mockResults), + }) + registry.register(handler) + + const results = await registry.search('/theme dark') + + expect(handler.search).toHaveBeenCalledWith('dark', 'en') + expect(results).toEqual(mockResults) + }) + + it('delegates to exact-match handler for command without args', async () => { + const handler = createHandler({ name: 'docs', search: vi.fn().mockResolvedValue([]) }) + registry.register(handler) + + await registry.search('/docs') + + expect(handler.search).toHaveBeenCalledWith('', 'en') + }) + + it('uses partial match when no exact match found', async () => { + const mockResults = [{ id: '1', title: 'T', description: '', type: 'command' as const, data: {} }] + const handler = createHandler({ + name: 'theme', + search: vi.fn().mockResolvedValue(mockResults), + }) + registry.register(handler) + + const results = await registry.search('/the') + + expect(results).toEqual(mockResults) + }) + + it('uses alias partial match', async () => { + const mockResults = [{ id: '1', title: 'L', description: '', type: 'command' as const, data: {} }] + const handler = createHandler({ + name: 'language', + aliases: ['lang'], + search: vi.fn().mockResolvedValue(mockResults), + }) + registry.register(handler) + + const results = await registry.search('/lan') + + expect(results).toEqual(mockResults) + }) + + it('falls back to fuzzy search when nothing matches', async () => { + registry.register(createHandler({ name: 'theme', description: 'Set theme' })) + + const results = await registry.search('/hem') + + expect(results).toHaveLength(1) + expect(results[0].title).toBe('/theme') + }) + + it('fuzzy search also matches aliases', async () => { + registry.register(createHandler({ name: 'language', aliases: ['lang'], description: 'Set language' })) + + const handler = registry.findCommand('language') + await registry.search('/lan') + expect(handler?.search).toHaveBeenCalled() + }) + + it('returns empty when handler.search throws', async () => { + const handler = createHandler({ + name: 'broken', + search: vi.fn().mockRejectedValue(new Error('fail')), + }) + registry.register(handler) + + const results = await registry.search('/broken') + expect(results).toEqual([]) + }) + + it('excludes unavailable commands from root listing', async () => { + registry.register(createHandler({ name: 'zen', isAvailable: () => false })) + registry.register(createHandler({ name: 'docs' })) + + const results = await registry.search('/') + expect(results).toHaveLength(1) + expect(results[0].title).toBe('/docs') + }) + + it('skips unavailable handler in exact match', async () => { + registry.register(createHandler({ name: 'zen', isAvailable: () => false })) + + const results = await registry.search('/zen') + expect(results).toEqual([]) + }) + + it('passes locale to handler search', async () => { + const handler = createHandler({ name: 'theme', search: vi.fn().mockResolvedValue([]) }) + registry.register(handler) + + await registry.search('/theme light', 'zh') + + expect(handler.search).toHaveBeenCalledWith('light', 'zh') + }) + }) + + describe('getCommandDependencies', () => { + it('returns stored deps', () => { + const deps = { setTheme: vi.fn() } + registry.register(createHandler({ name: 'theme' }), deps) + + expect(registry.getCommandDependencies('theme')).toBe(deps) + }) + + it('returns undefined when no deps stored', () => { + registry.register(createHandler({ name: 'docs' })) + + expect(registry.getCommandDependencies('docs')).toBeUndefined() + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts new file mode 100644 index 0000000000..3dd45aad11 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts @@ -0,0 +1,73 @@ +import { registerCommands, unregisterCommands } from '../command-bus' +import { themeCommand } from '../theme' + +vi.mock('../command-bus') + +vi.mock('react-i18next', () => ({ + getI18n: () => ({ + t: (key: string) => key, + }), +})) + +describe('themeCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('has correct metadata', () => { + expect(themeCommand.name).toBe('theme') + expect(themeCommand.mode).toBe('submenu') + expect(themeCommand.execute).toBeUndefined() + }) + + describe('search', () => { + it('returns all theme options when query is empty', async () => { + const results = await themeCommand.search('', 'en') + + expect(results).toHaveLength(3) + expect(results.map(r => r.id)).toEqual(['system', 'light', 'dark']) + }) + + it('returns all theme options with correct type', async () => { + const results = await themeCommand.search('', 'en') + + results.forEach((r) => { + expect(r.type).toBe('command') + expect(r.data).toEqual({ command: 'theme.set', args: expect.objectContaining({ value: expect.any(String) }) }) + }) + }) + + it('filters results by query matching id', async () => { + const results = await themeCommand.search('dark', 'en') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('dark') + }) + }) + + describe('register / unregister', () => { + it('registers theme.set command with deps', () => { + const deps = { setTheme: vi.fn() } + themeCommand.register?.(deps) + + expect(registerCommands).toHaveBeenCalledWith({ 'theme.set': expect.any(Function) }) + }) + + it('unregisters theme.set command', () => { + themeCommand.unregister?.() + + expect(unregisterCommands).toHaveBeenCalledWith(['theme.set']) + }) + + it('registered handler calls setTheme', async () => { + const setTheme = vi.fn() + vi.mocked(registerCommands).mockImplementation((map) => { + map['theme.set']?.({ value: 'dark' }) + }) + + themeCommand.register?.({ setTheme }) + + expect(setTheme).toHaveBeenCalledWith('dark') + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/zen.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/zen.spec.ts new file mode 100644 index 0000000000..623cbda140 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/zen.spec.ts @@ -0,0 +1,84 @@ +import { registerCommands, unregisterCommands } from '../command-bus' +import { ZEN_TOGGLE_EVENT, zenCommand } from '../zen' + +vi.mock('../command-bus') + +vi.mock('react-i18next', () => ({ + getI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/workflow/constants', () => ({ + isInWorkflowPage: vi.fn(() => true), +})) + +describe('zenCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('has correct metadata', () => { + expect(zenCommand.name).toBe('zen') + expect(zenCommand.mode).toBe('direct') + expect(zenCommand.execute).toBeDefined() + }) + + it('exports ZEN_TOGGLE_EVENT constant', () => { + expect(ZEN_TOGGLE_EVENT).toBe('zen-toggle-maximize') + }) + + describe('isAvailable', () => { + it('delegates to isInWorkflowPage', async () => { + const { isInWorkflowPage } = vi.mocked( + await import('@/app/components/workflow/constants'), + ) + + isInWorkflowPage.mockReturnValue(true) + expect(zenCommand.isAvailable?.()).toBe(true) + + isInWorkflowPage.mockReturnValue(false) + expect(zenCommand.isAvailable?.()).toBe(false) + }) + }) + + describe('execute', () => { + it('dispatches custom zen-toggle event', () => { + const dispatchSpy = vi.spyOn(window, 'dispatchEvent') + + zenCommand.execute?.() + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: ZEN_TOGGLE_EVENT }), + ) + dispatchSpy.mockRestore() + }) + }) + + describe('search', () => { + it('returns single zen mode result', async () => { + const results = await zenCommand.search('', 'en') + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + id: 'zen', + type: 'command', + data: { command: 'workflow.zen', args: {} }, + }) + }) + }) + + describe('register / unregister', () => { + it('registers workflow.zen command', () => { + zenCommand.register?.({} as Record<string, never>) + + expect(registerCommands).toHaveBeenCalledWith({ 'workflow.zen': expect.any(Function) }) + }) + + it('unregisters workflow.zen command', () => { + zenCommand.unregister?.() + + expect(unregisterCommands).toHaveBeenCalledWith(['workflow.zen']) + }) + }) +}) diff --git a/web/app/components/goto-anything/components/empty-state.spec.tsx b/web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx similarity index 88% rename from web/app/components/goto-anything/components/empty-state.spec.tsx rename to web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx index e1e5e0dc89..8921f5b897 100644 --- a/web/app/components/goto-anything/components/empty-state.spec.tsx +++ b/web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx @@ -1,15 +1,5 @@ import { render, screen } from '@testing-library/react' -import EmptyState from './empty-state' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string, shortcuts?: string }) => { - if (options?.shortcuts !== undefined) - return `${key}:${options.shortcuts}` - return `${options?.ns || 'common'}.${key}` - }, - }), -})) +import EmptyState from '../empty-state' describe('EmptyState', () => { describe('loading variant', () => { @@ -86,10 +76,10 @@ describe('EmptyState', () => { const Actions = { app: { key: '@app', shortcut: '@app' }, plugin: { key: '@plugin', shortcut: '@plugin' }, - } as unknown as Record<string, import('../actions/types').ActionItem> + } as unknown as Record<string, import('../../actions/types').ActionItem> render(<EmptyState variant="no-results" searchMode="general" Actions={Actions} />) - expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:@app, @plugin')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":"@app, @plugin"}')).toBeInTheDocument() }) }) @@ -150,8 +140,7 @@ describe('EmptyState', () => { it('should use empty object as default Actions', () => { render(<EmptyState variant="no-results" searchMode="general" />) - // Should show empty shortcuts - expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":""}')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/goto-anything/components/footer.spec.tsx b/web/app/components/goto-anything/components/__tests__/footer.spec.tsx similarity index 92% rename from web/app/components/goto-anything/components/footer.spec.tsx rename to web/app/components/goto-anything/components/__tests__/footer.spec.tsx index 3dfac5f71c..93239079de 100644 --- a/web/app/components/goto-anything/components/footer.spec.tsx +++ b/web/app/components/goto-anything/components/__tests__/footer.spec.tsx @@ -1,17 +1,5 @@ import { render, screen } from '@testing-library/react' -import Footer from './footer' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string, count?: number, scope?: string }) => { - if (options?.count !== undefined) - return `${key}:${options.count}` - if (options?.scope) - return `${key}:${options.scope}` - return `${options?.ns || 'common'}.${key}` - }, - }), -})) +import Footer from '../footer' describe('Footer', () => { describe('left content', () => { @@ -27,7 +15,7 @@ describe('Footer', () => { />, ) - expect(screen.getByText('gotoAnything.resultCount:5')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.resultCount:{"count":5}')).toBeInTheDocument() }) it('should show scope when not in general mode', () => { @@ -41,7 +29,7 @@ describe('Footer', () => { />, ) - expect(screen.getByText('gotoAnything.inScope:app')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.inScope:{"scope":"app"}')).toBeInTheDocument() }) it('should NOT show scope when in general mode', () => { diff --git a/web/app/components/goto-anything/components/__tests__/result-item.spec.tsx b/web/app/components/goto-anything/components/__tests__/result-item.spec.tsx new file mode 100644 index 0000000000..068e5db3e7 --- /dev/null +++ b/web/app/components/goto-anything/components/__tests__/result-item.spec.tsx @@ -0,0 +1,82 @@ +import type { SearchResult } from '../../actions/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Command } from 'cmdk' +import ResultItem from '../result-item' + +function renderInCommandRoot(ui: React.ReactElement) { + return render(<Command>{ui}</Command>) +} + +function createResult(overrides: Partial<SearchResult> = {}): SearchResult { + return { + id: 'test-1', + title: 'Test Result', + type: 'app', + data: {}, + ...overrides, + } as SearchResult +} + +describe('ResultItem', () => { + it('renders title', () => { + renderInCommandRoot( + <ResultItem result={createResult({ title: 'My App' })} onSelect={vi.fn()} />, + ) + + expect(screen.getByText('My App')).toBeInTheDocument() + }) + + it('renders description when provided', () => { + renderInCommandRoot( + <ResultItem + result={createResult({ description: 'A great app' })} + onSelect={vi.fn()} + />, + ) + + expect(screen.getByText('A great app')).toBeInTheDocument() + }) + + it('does not render description when absent', () => { + const result = createResult() + delete (result as Record<string, unknown>).description + + renderInCommandRoot( + <ResultItem result={result} onSelect={vi.fn()} />, + ) + + expect(screen.getByText('Test Result')).toBeInTheDocument() + expect(screen.getByText('app')).toBeInTheDocument() + }) + + it('renders result type label', () => { + renderInCommandRoot( + <ResultItem result={createResult({ type: 'plugin' })} onSelect={vi.fn()} />, + ) + + expect(screen.getByText('plugin')).toBeInTheDocument() + }) + + it('renders icon when provided', () => { + const icon = <span data-testid="custom-icon">icon</span> + renderInCommandRoot( + <ResultItem result={createResult({ icon })} onSelect={vi.fn()} />, + ) + + expect(screen.getByTestId('custom-icon')).toBeInTheDocument() + }) + + it('calls onSelect when clicked', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + renderInCommandRoot( + <ResultItem result={createResult()} onSelect={onSelect} />, + ) + + await user.click(screen.getByText('Test Result')) + + expect(onSelect).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/goto-anything/components/__tests__/result-list.spec.tsx b/web/app/components/goto-anything/components/__tests__/result-list.spec.tsx new file mode 100644 index 0000000000..746e6110b8 --- /dev/null +++ b/web/app/components/goto-anything/components/__tests__/result-list.spec.tsx @@ -0,0 +1,86 @@ +import type { SearchResult } from '../../actions/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Command } from 'cmdk' +import ResultList from '../result-list' + +function renderInCommandRoot(ui: React.ReactElement) { + return render(<Command>{ui}</Command>) +} + +function createResult(overrides: Partial<SearchResult> = {}): SearchResult { + return { + id: 'test-1', + title: 'Result 1', + type: 'app', + data: {}, + ...overrides, + } as SearchResult +} + +describe('ResultList', () => { + it('renders grouped results with headings', () => { + const grouped: Record<string, SearchResult[]> = { + app: [createResult({ id: 'a1', title: 'App One', type: 'app' })], + plugin: [createResult({ id: 'p1', title: 'Plugin One', type: 'plugin' })], + } + + renderInCommandRoot( + <ResultList groupedResults={grouped} onSelect={vi.fn()} />, + ) + + expect(screen.getByText('App One')).toBeInTheDocument() + expect(screen.getByText('Plugin One')).toBeInTheDocument() + }) + + it('renders multiple results in the same group', () => { + const grouped: Record<string, SearchResult[]> = { + app: [ + createResult({ id: 'a1', title: 'App One', type: 'app' }), + createResult({ id: 'a2', title: 'App Two', type: 'app' }), + ], + } + + renderInCommandRoot( + <ResultList groupedResults={grouped} onSelect={vi.fn()} />, + ) + + expect(screen.getByText('App One')).toBeInTheDocument() + expect(screen.getByText('App Two')).toBeInTheDocument() + }) + + it('calls onSelect with the correct result when clicked', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const result = createResult({ id: 'a1', title: 'Click Me', type: 'app' }) + + renderInCommandRoot( + <ResultList groupedResults={{ app: [result] }} onSelect={onSelect} />, + ) + + await user.click(screen.getByText('Click Me')) + + expect(onSelect).toHaveBeenCalledWith(result) + }) + + it('renders empty when no grouped results provided', () => { + const { container } = renderInCommandRoot( + <ResultList groupedResults={{}} onSelect={vi.fn()} />, + ) + + const groups = container.querySelectorAll('[cmdk-group]') + expect(groups).toHaveLength(0) + }) + + it('uses i18n keys for known group types', () => { + const grouped: Record<string, SearchResult[]> = { + command: [createResult({ id: 'c1', title: 'Cmd', type: 'command' })], + } + + renderInCommandRoot( + <ResultList groupedResults={grouped} onSelect={vi.fn()} />, + ) + + expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/goto-anything/components/search-input.spec.tsx b/web/app/components/goto-anything/components/__tests__/search-input.spec.tsx similarity index 96% rename from web/app/components/goto-anything/components/search-input.spec.tsx rename to web/app/components/goto-anything/components/__tests__/search-input.spec.tsx index 99c0f56d56..781531a341 100644 --- a/web/app/components/goto-anything/components/search-input.spec.tsx +++ b/web/app/components/goto-anything/components/__tests__/search-input.spec.tsx @@ -1,12 +1,6 @@ import type { ChangeEvent, KeyboardEvent, RefObject } from 'react' import { fireEvent, render, screen } from '@testing-library/react' -import SearchInput from './search-input' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => `${options?.ns || 'common'}.${key}`, - }), -})) +import SearchInput from '../search-input' vi.mock('@remixicon/react', () => ({ RiSearchLine: ({ className }: { className?: string }) => ( diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-modal.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts similarity index 91% rename from web/app/components/goto-anything/hooks/use-goto-anything-modal.spec.ts rename to web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts index 89d05be25e..45bbfb7447 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-modal.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts @@ -1,5 +1,5 @@ import { act, renderHook } from '@testing-library/react' -import { useGotoAnythingModal } from './use-goto-anything-modal' +import { useGotoAnythingModal } from '../use-goto-anything-modal' type KeyPressEvent = { preventDefault: () => void @@ -94,20 +94,17 @@ describe('useGotoAnythingModal', () => { keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body }) }) - // Should remain closed because focus is in input area expect(result.current.show).toBe(false) }) it('should close modal when escape is pressed and modal is open', () => { const { result } = renderHook(() => useGotoAnythingModal()) - // Open modal first act(() => { result.current.setShow(true) }) expect(result.current.show).toBe(true) - // Press escape act(() => { keyPressHandlers.esc?.({ preventDefault: vi.fn() }) }) @@ -125,7 +122,6 @@ describe('useGotoAnythingModal', () => { keyPressHandlers.esc?.({ preventDefault: preventDefaultMock }) }) - // Should remain closed, and preventDefault should not be called expect(result.current.show).toBe(false) expect(preventDefaultMock).not.toHaveBeenCalled() }) @@ -146,13 +142,11 @@ describe('useGotoAnythingModal', () => { it('should close modal when handleClose is called', () => { const { result } = renderHook(() => useGotoAnythingModal()) - // Open modal first act(() => { result.current.setShow(true) }) expect(result.current.show).toBe(true) - // Close via handleClose act(() => { result.current.handleClose() }) @@ -219,14 +213,12 @@ describe('useGotoAnythingModal', () => { it('should not call requestAnimationFrame when modal closes', () => { const { result } = renderHook(() => useGotoAnythingModal()) - // First open act(() => { result.current.setShow(true) }) const rafSpy = vi.spyOn(window, 'requestAnimationFrame') - // Then close act(() => { result.current.setShow(false) }) @@ -236,7 +228,6 @@ describe('useGotoAnythingModal', () => { }) it('should focus input when modal opens and inputRef.current exists', () => { - // Mock requestAnimationFrame to execute callback immediately const originalRAF = window.requestAnimationFrame window.requestAnimationFrame = (callback: FrameRequestCallback) => { callback(0) @@ -245,11 +236,9 @@ describe('useGotoAnythingModal', () => { const { result } = renderHook(() => useGotoAnythingModal()) - // Create a mock input element with focus method const mockFocus = vi.fn() const mockInput = { focus: mockFocus } as unknown as HTMLInputElement - // Manually set the inputRef Object.defineProperty(result.current.inputRef, 'current', { value: mockInput, writable: true, @@ -261,12 +250,10 @@ describe('useGotoAnythingModal', () => { expect(mockFocus).toHaveBeenCalled() - // Restore original requestAnimationFrame window.requestAnimationFrame = originalRAF }) it('should not throw when inputRef.current is null when modal opens', () => { - // Mock requestAnimationFrame to execute callback immediately const originalRAF = window.requestAnimationFrame window.requestAnimationFrame = (callback: FrameRequestCallback) => { callback(0) @@ -275,16 +262,12 @@ describe('useGotoAnythingModal', () => { const { result } = renderHook(() => useGotoAnythingModal()) - // inputRef.current is already null by default - - // Should not throw act(() => { result.current.setShow(true) }) expect(result.current.show).toBe(true) - // Restore original requestAnimationFrame window.requestAnimationFrame = originalRAF }) }) diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts similarity index 95% rename from web/app/components/goto-anything/hooks/use-goto-anything-navigation.spec.ts rename to web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts index efb15f41b3..1ac3bbc17c 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts @@ -1,10 +1,10 @@ import type * as React from 'react' -import type { Plugin } from '../../plugins/types' -import type { CommonNodeType } from '../../workflow/types' +import type { Plugin } from '../../../plugins/types' +import type { CommonNodeType } from '../../../workflow/types' import type { DataSet } from '@/models/datasets' import type { App } from '@/types/app' import { act, renderHook } from '@testing-library/react' -import { useGotoAnythingNavigation } from './use-goto-anything-navigation' +import { useGotoAnythingNavigation } from '../use-goto-anything-navigation' const mockRouterPush = vi.fn() const mockSelectWorkflowNode = vi.fn() @@ -26,7 +26,7 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({ selectWorkflowNode: (...args: unknown[]) => mockSelectWorkflowNode(...args), })) -vi.mock('../actions/commands/registry', () => ({ +vi.mock('../../actions/commands/registry', () => ({ slashCommandRegistry: { findCommand: () => mockFindCommandResult, }, @@ -117,7 +117,6 @@ describe('useGotoAnythingNavigation', () => { }) expect(options.onClose).not.toHaveBeenCalled() - // Should proceed with submenu mode expect(options.setSearchQuery).toHaveBeenCalledWith('/theme ') }) @@ -177,7 +176,6 @@ describe('useGotoAnythingNavigation', () => { result.current.handleCommandSelect('/unknown') }) - // Should proceed with submenu mode expect(options.setSearchQuery).toHaveBeenCalledWith('/unknown ') }) }) @@ -333,13 +331,11 @@ describe('useGotoAnythingNavigation', () => { it('should clear activePlugin when set to undefined', () => { const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions())) - // First set a plugin act(() => { result.current.setActivePlugin({ name: 'Plugin', latest_package_identifier: 'pkg' } as unknown as Plugin) }) expect(result.current.activePlugin).toBeDefined() - // Then clear it act(() => { result.current.setActivePlugin(undefined) }) @@ -356,7 +352,6 @@ describe('useGotoAnythingNavigation', () => { const { result } = renderHook(() => useGotoAnythingNavigation(options)) - // Should not throw act(() => { result.current.handleCommandSelect('@app') }) @@ -364,8 +359,6 @@ describe('useGotoAnythingNavigation', () => { act(() => { vi.runAllTimers() }) - - // No error should occur }) it('should handle missing slash action', () => { @@ -375,7 +368,6 @@ describe('useGotoAnythingNavigation', () => { const { result } = renderHook(() => useGotoAnythingNavigation(options)) - // Should not throw act(() => { result.current.handleNavigate({ id: 'cmd-1', @@ -384,8 +376,6 @@ describe('useGotoAnythingNavigation', () => { data: { command: 'test-command' }, }) }) - - // No error should occur }) }) }) diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-results.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts similarity index 97% rename from web/app/components/goto-anything/hooks/use-goto-anything-results.spec.ts rename to web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts index ca95abeacd..faaf0bbd1e 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-results.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts @@ -1,6 +1,6 @@ -import type { SearchResult } from '../actions/types' +import type { SearchResult } from '../../actions/types' import { renderHook } from '@testing-library/react' -import { useGotoAnythingResults } from './use-goto-anything-results' +import { useGotoAnythingResults } from '../use-goto-anything-results' type MockQueryResult = { data: Array<{ id: string, type: string, title: string }> | undefined @@ -30,7 +30,7 @@ vi.mock('@/context/i18n', () => ({ const mockMatchAction = vi.fn() const mockSearchAnything = vi.fn() -vi.mock('../actions', () => ({ +vi.mock('../../actions', () => ({ matchAction: (...args: unknown[]) => mockMatchAction(...args), searchAnything: (...args: unknown[]) => mockSearchAnything(...args), })) @@ -139,7 +139,6 @@ describe('useGotoAnythingResults', () => { const { result } = renderHook(() => useGotoAnythingResults(createMockOptions())) - // Different types, same id = different keys, so both should remain expect(result.current.dedupedResults).toHaveLength(2) }) }) diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-search.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-search.spec.ts similarity index 96% rename from web/app/components/goto-anything/hooks/use-goto-anything-search.spec.ts rename to web/app/components/goto-anything/hooks/__tests__/use-goto-anything-search.spec.ts index d8987c2d9c..f13fb21704 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-search.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-search.spec.ts @@ -1,6 +1,6 @@ -import type { ActionItem } from '../actions/types' +import type { ActionItem } from '../../actions/types' import { act, renderHook } from '@testing-library/react' -import { useGotoAnythingSearch } from './use-goto-anything-search' +import { useGotoAnythingSearch } from '../use-goto-anything-search' let mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false } let mockMatchActionResult: Partial<ActionItem> | undefined @@ -9,11 +9,11 @@ vi.mock('ahooks', () => ({ useDebounce: <T>(value: T) => value, })) -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useGotoAnythingContext: () => mockContextValue, })) -vi.mock('../actions', () => ({ +vi.mock('../../actions', () => ({ createActions: (isWorkflowPage: boolean, isRagPipelinePage: boolean) => { const base = { slash: { key: '/', shortcut: '/' }, @@ -233,13 +233,11 @@ describe('useGotoAnythingSearch', () => { it('should reset cmdVal to "_"', () => { const { result } = renderHook(() => useGotoAnythingSearch()) - // First change cmdVal act(() => { result.current.setCmdVal('app-1') }) expect(result.current.cmdVal).toBe('app-1') - // Then clear act(() => { result.current.clearSelection() }) @@ -294,7 +292,6 @@ describe('useGotoAnythingSearch', () => { result.current.setSearchQuery(' test ') }) - // Since we mock useDebounce to return value directly expect(result.current.searchQueryDebouncedValue).toBe('test') }) }) diff --git a/web/app/components/share/utils.spec.ts b/web/app/components/share/__tests__/utils.spec.ts similarity index 97% rename from web/app/components/share/utils.spec.ts rename to web/app/components/share/__tests__/utils.spec.ts index ee2aab58eb..1cf12f7508 100644 --- a/web/app/components/share/utils.spec.ts +++ b/web/app/components/share/__tests__/utils.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getInitialTokenV2, isTokenV1 } from './utils' +import { getInitialTokenV2, isTokenV1 } from '../utils' describe('utils', () => { describe('isTokenV1', () => { diff --git a/web/app/components/share/text-generation/info-modal.spec.tsx b/web/app/components/share/text-generation/__tests__/info-modal.spec.tsx similarity index 73% rename from web/app/components/share/text-generation/info-modal.spec.tsx rename to web/app/components/share/text-generation/__tests__/info-modal.spec.tsx index 025c5edde1..972c22dfce 100644 --- a/web/app/components/share/text-generation/info-modal.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/info-modal.spec.tsx @@ -1,19 +1,26 @@ import type { SiteInfo } from '@/models/share' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import InfoModal from './info-modal' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import InfoModal from '../info-modal' -// Only mock react-i18next for translations -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() cleanup() }) +async function renderModal(ui: React.ReactElement) { + const result = render(ui) + await act(async () => { + vi.runAllTimers() + }) + return result +} + describe('InfoModal', () => { const mockOnClose = vi.fn() @@ -29,8 +36,8 @@ describe('InfoModal', () => { }) describe('rendering', () => { - it('should not render when isShow is false', () => { - render( + it('should not render when isShow is false', async () => { + await renderModal( <InfoModal isShow={false} onClose={mockOnClose} @@ -41,8 +48,8 @@ describe('InfoModal', () => { expect(screen.queryByText('Test App')).not.toBeInTheDocument() }) - it('should render when isShow is true', () => { - render( + it('should render when isShow is true', async () => { + await renderModal( <InfoModal isShow={true} onClose={mockOnClose} @@ -53,8 +60,8 @@ describe('InfoModal', () => { expect(screen.getByText('Test App')).toBeInTheDocument() }) - it('should render app title', () => { - render( + it('should render app title', async () => { + await renderModal( <InfoModal isShow={true} onClose={mockOnClose} @@ -65,13 +72,13 @@ describe('InfoModal', () => { expect(screen.getByText('Test App')).toBeInTheDocument() }) - it('should render copyright when provided', () => { + it('should render copyright when provided', async () => { const siteInfoWithCopyright: SiteInfo = { ...baseSiteInfo, copyright: 'Dify Inc.', } - render( + await renderModal( <InfoModal isShow={true} onClose={mockOnClose} @@ -82,13 +89,13 @@ describe('InfoModal', () => { expect(screen.getByText(/Dify Inc./)).toBeInTheDocument() }) - it('should render current year in copyright', () => { + it('should render current year in copyright', async () => { const siteInfoWithCopyright: SiteInfo = { ...baseSiteInfo, copyright: 'Test Company', } - render( + await renderModal( <InfoModal isShow={true} onClose={mockOnClose} @@ -100,13 +107,13 @@ describe('InfoModal', () => { expect(screen.getByText(new RegExp(currentYear))).toBeInTheDocument() }) - it('should render custom disclaimer when provided', () => { + it('should render custom disclaimer when provided', async () => { const siteInfoWithDisclaimer: SiteInfo = { ...baseSiteInfo, custom_disclaimer: 'This is a custom disclaimer', } - render( + await renderModal( <InfoModal isShow={true} onClose={mockOnClose} @@ -117,8 +124,8 @@ describe('InfoModal', () => { expect(screen.getByText('This is a custom disclaimer')).toBeInTheDocument() }) - it('should not render copyright section when not provided', () => { - render( + it('should not render copyright section when not provided', async () => { + await renderModal( <InfoModal isShow={true} onClose={mockOnClose} @@ -130,8 +137,8 @@ describe('InfoModal', () => { expect(screen.queryByText(new RegExp(`©.*${year}`))).not.toBeInTheDocument() }) - it('should render with undefined data', () => { - render( + it('should render with undefined data', async () => { + await renderModal( <InfoModal isShow={true} onClose={mockOnClose} @@ -139,18 +146,17 @@ describe('InfoModal', () => { />, ) - // Modal should still render but without content expect(screen.queryByText('Test App')).not.toBeInTheDocument() }) - it('should render with image icon type', () => { + it('should render with image icon type', async () => { const siteInfoWithImage: SiteInfo = { ...baseSiteInfo, icon_type: 'image', icon_url: 'https://example.com/icon.png', } - render( + await renderModal( <InfoModal isShow={true} onClose={mockOnClose} @@ -163,8 +169,8 @@ describe('InfoModal', () => { }) describe('close functionality', () => { - it('should call onClose when close button is clicked', () => { - render( + it('should call onClose when close button is clicked', async () => { + await renderModal( <InfoModal isShow={true} onClose={mockOnClose} @@ -172,7 +178,6 @@ describe('InfoModal', () => { />, ) - // Find the close icon (RiCloseLine) which has text-text-tertiary class const closeIcon = document.querySelector('[class*="text-text-tertiary"]') expect(closeIcon).toBeInTheDocument() if (closeIcon) { @@ -183,14 +188,14 @@ describe('InfoModal', () => { }) describe('both copyright and disclaimer', () => { - it('should render both when both are provided', () => { + it('should render both when both are provided', async () => { const siteInfoWithBoth: SiteInfo = { ...baseSiteInfo, copyright: 'My Company', custom_disclaimer: 'Disclaimer text here', } - render( + await renderModal( <InfoModal isShow={true} onClose={mockOnClose} diff --git a/web/app/components/share/text-generation/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx similarity index 80% rename from web/app/components/share/text-generation/menu-dropdown.spec.tsx rename to web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx index b54a2df632..46d229c6b6 100644 --- a/web/app/components/share/text-generation/menu-dropdown.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx @@ -1,16 +1,8 @@ import type { SiteInfo } from '@/models/share' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import MenuDropdown from './menu-dropdown' +import MenuDropdown from '../menu-dropdown' -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock next/navigation const mockReplace = vi.fn() const mockPathname = '/test-path' vi.mock('next/navigation', () => ({ @@ -20,7 +12,6 @@ vi.mock('next/navigation', () => ({ usePathname: () => mockPathname, })) -// Mock web-app-context const mockShareCode = 'test-share-code' vi.mock('@/context/web-app-context', () => ({ useWebAppStore: (selector: (state: Record<string, unknown>) => unknown) => { @@ -32,7 +23,6 @@ vi.mock('@/context/web-app-context', () => ({ }, })) -// Mock webapp-auth service const mockWebAppLogout = vi.fn().mockResolvedValue(undefined) vi.mock('@/service/webapp-auth', () => ({ webAppLogout: (...args: unknown[]) => mockWebAppLogout(...args), @@ -57,7 +47,6 @@ describe('MenuDropdown', () => { it('should render the trigger button', () => { render(<MenuDropdown data={baseSiteInfo} />) - // The trigger button contains a settings icon (RiEqualizer2Line) const triggerButton = screen.getByRole('button') expect(triggerButton).toBeInTheDocument() }) @@ -65,8 +54,7 @@ describe('MenuDropdown', () => { it('should not show dropdown content initially', () => { render(<MenuDropdown data={baseSiteInfo} />) - // Dropdown content should not be visible initially - expect(screen.queryByText('theme.theme')).not.toBeInTheDocument() + expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument() }) it('should show dropdown content when clicked', async () => { @@ -76,7 +64,7 @@ describe('MenuDropdown', () => { fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.getByText('theme.theme')).toBeInTheDocument() + expect(screen.getByText('common.theme.theme')).toBeInTheDocument() }) }) @@ -87,7 +75,7 @@ describe('MenuDropdown', () => { fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.getByText('userProfile.about')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.about')).toBeInTheDocument() }) }) }) @@ -105,7 +93,7 @@ describe('MenuDropdown', () => { fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.getByText('chat.privacyPolicyMiddle')).toBeInTheDocument() + expect(screen.getByText('share.chat.privacyPolicyMiddle')).toBeInTheDocument() }) }) @@ -116,7 +104,7 @@ describe('MenuDropdown', () => { fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.queryByText('chat.privacyPolicyMiddle')).not.toBeInTheDocument() + expect(screen.queryByText('share.chat.privacyPolicyMiddle')).not.toBeInTheDocument() }) }) @@ -133,7 +121,7 @@ describe('MenuDropdown', () => { fireEvent.click(triggerButton) await waitFor(() => { - const link = screen.getByText('chat.privacyPolicyMiddle').closest('a') + const link = screen.getByText('share.chat.privacyPolicyMiddle').closest('a') expect(link).toHaveAttribute('href', privacyUrl) expect(link).toHaveAttribute('target', '_blank') }) @@ -148,7 +136,7 @@ describe('MenuDropdown', () => { fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.getByText('userProfile.logout')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.logout')).toBeInTheDocument() }) }) @@ -159,7 +147,7 @@ describe('MenuDropdown', () => { fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.queryByText('userProfile.logout')).not.toBeInTheDocument() + expect(screen.queryByText('common.userProfile.logout')).not.toBeInTheDocument() }) }) @@ -170,10 +158,10 @@ describe('MenuDropdown', () => { fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.getByText('userProfile.logout')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.logout')).toBeInTheDocument() }) - const logoutButton = screen.getByText('userProfile.logout') + const logoutButton = screen.getByText('common.userProfile.logout') await act(async () => { fireEvent.click(logoutButton) }) @@ -193,10 +181,10 @@ describe('MenuDropdown', () => { fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.getByText('userProfile.about')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.about')).toBeInTheDocument() }) - const aboutButton = screen.getByText('userProfile.about') + const aboutButton = screen.getByText('common.userProfile.about') fireEvent.click(aboutButton) await waitFor(() => { @@ -213,13 +201,13 @@ describe('MenuDropdown', () => { fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.getByText('theme.theme')).toBeInTheDocument() + expect(screen.getByText('common.theme.theme')).toBeInTheDocument() }) rerender(<MenuDropdown data={baseSiteInfo} forceClose={true} />) await waitFor(() => { - expect(screen.queryByText('theme.theme')).not.toBeInTheDocument() + expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument() }) }) }) @@ -239,16 +227,14 @@ describe('MenuDropdown', () => { const triggerButton = screen.getByRole('button') - // Open fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.getByText('theme.theme')).toBeInTheDocument() + expect(screen.getByText('common.theme.theme')).toBeInTheDocument() }) - // Close fireEvent.click(triggerButton) await waitFor(() => { - expect(screen.queryByText('theme.theme')).not.toBeInTheDocument() + expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/share/text-generation/no-data/index.spec.tsx b/web/app/components/share/text-generation/no-data/__tests__/index.spec.tsx similarity index 93% rename from web/app/components/share/text-generation/no-data/index.spec.tsx rename to web/app/components/share/text-generation/no-data/__tests__/index.spec.tsx index 41de9907fd..68e161c9b3 100644 --- a/web/app/components/share/text-generation/no-data/index.spec.tsx +++ b/web/app/components/share/text-generation/no-data/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import NoData from './index' +import NoData from '../index' describe('NoData', () => { beforeEach(() => { diff --git a/web/app/components/share/text-generation/run-batch/index.spec.tsx b/web/app/components/share/text-generation/run-batch/__tests__/index.spec.tsx similarity index 96% rename from web/app/components/share/text-generation/run-batch/index.spec.tsx rename to web/app/components/share/text-generation/run-batch/__tests__/index.spec.tsx index 4344ea2156..63aa04e29a 100644 --- a/web/app/components/share/text-generation/run-batch/index.spec.tsx +++ b/web/app/components/share/text-generation/run-batch/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { Mock } from 'vitest' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import RunBatch from './index' +import RunBatch from '../index' vi.mock('@/hooks/use-breakpoints', async (importOriginal) => { const actual = await importOriginal<typeof import('@/hooks/use-breakpoints')>() @@ -15,14 +15,14 @@ vi.mock('@/hooks/use-breakpoints', async (importOriginal) => { let latestOnParsed: ((data: string[][]) => void) | undefined let receivedCSVDownloadProps: Record<string, unknown> | undefined -vi.mock('./csv-reader', () => ({ +vi.mock('../csv-reader', () => ({ default: (props: { onParsed: (data: string[][]) => void }) => { latestOnParsed = props.onParsed return <div data-testid="csv-reader" /> }, })) -vi.mock('./csv-download', () => ({ +vi.mock('../csv-download', () => ({ default: (props: { vars: { name: string }[] }) => { receivedCSVDownloadProps = props return <div data-testid="csv-download" /> diff --git a/web/app/components/share/text-generation/run-batch/csv-download/index.spec.tsx b/web/app/components/share/text-generation/run-batch/csv-download/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/share/text-generation/run-batch/csv-download/index.spec.tsx rename to web/app/components/share/text-generation/run-batch/csv-download/__tests__/index.spec.tsx index 120e3ed0c2..6a9bb21797 100644 --- a/web/app/components/share/text-generation/run-batch/csv-download/index.spec.tsx +++ b/web/app/components/share/text-generation/run-batch/csv-download/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import CSVDownload from './index' +import CSVDownload from '../index' const mockType = { Link: 'mock-link' } let capturedProps: Record<string, unknown> | undefined diff --git a/web/app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx b/web/app/components/share/text-generation/run-batch/csv-reader/__tests__/index.spec.tsx similarity index 81% rename from web/app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx rename to web/app/components/share/text-generation/run-batch/csv-reader/__tests__/index.spec.tsx index 83e89a0a04..f1361965a5 100644 --- a/web/app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx +++ b/web/app/components/share/text-generation/run-batch/csv-reader/__tests__/index.spec.tsx @@ -1,13 +1,20 @@ import { act, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import CSVReader from './index' +import CSVReader from '../index' let mockAcceptedFile: { name: string } | null = null -let capturedHandlers: Record<string, (payload: any) => void> = {} + +type CSVReaderHandlers = { + onUploadAccepted?: (payload: { data: string[][] }) => void + onDragOver?: (event: DragEvent) => void + onDragLeave?: (event: DragEvent) => void +} + +let capturedHandlers: CSVReaderHandlers = {} vi.mock('react-papaparse', () => ({ useCSVReader: () => ({ - CSVReader: ({ children, ...handlers }: any) => { + CSVReader: ({ children, ...handlers }: { children: (ctx: { getRootProps: () => Record<string, string>, acceptedFile: { name: string } | null }) => React.ReactNode } & CSVReaderHandlers) => { capturedHandlers = handlers return ( <div data-testid="csv-reader-wrapper"> diff --git a/web/app/components/share/text-generation/run-batch/res-download/index.spec.tsx b/web/app/components/share/text-generation/run-batch/res-download/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/share/text-generation/run-batch/res-download/index.spec.tsx rename to web/app/components/share/text-generation/run-batch/res-download/__tests__/index.spec.tsx index b71b252345..2419a570f1 100644 --- a/web/app/components/share/text-generation/run-batch/res-download/index.spec.tsx +++ b/web/app/components/share/text-generation/run-batch/res-download/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import ResDownload from './index' +import ResDownload from '../index' const mockType = { Link: 'mock-link' } let capturedProps: Record<string, unknown> | undefined diff --git a/web/app/components/share/text-generation/run-once/index.spec.tsx b/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/share/text-generation/run-once/index.spec.tsx rename to web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx index af3d723d20..65043ce0c2 100644 --- a/web/app/components/share/text-generation/run-once/index.spec.tsx +++ b/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { InputValueTypes } from '../types' +import type { InputValueTypes } from '../../types' import type { PromptConfig, PromptVariable } from '@/models/debug' import type { SiteInfo } from '@/models/share' import type { VisionFile, VisionSettings } from '@/types/app' @@ -6,7 +6,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { useEffect, useRef, useState } from 'react' import { Resolution, TransferMethod } from '@/types/app' -import RunOnce from './index' +import RunOnce from '../index' vi.mock('@/hooks/use-breakpoints', () => { const MediaType = { @@ -39,7 +39,6 @@ vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', ( } }) -// Mock FileUploaderInAttachmentWrapper as it requires context providers not available in tests vi.mock('@/app/components/base/file-uploader', () => ({ FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: object[], onChange: (files: object[]) => void }) => ( <div data-testid="file-uploader-mock"> @@ -272,7 +271,6 @@ describe('RunOnce', () => { selectInput: 'Option A', }) }) - // The Select component should be rendered expect(screen.getByText('Select Input')).toBeInTheDocument() }) }) @@ -463,7 +461,6 @@ describe('RunOnce', () => { key: 'textInput', name: 'Text Input', type: 'string', - // max_length is not set }), ], } diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 3a518544e8..eff3e27589 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -32,11 +32,6 @@ "count": 2 } }, - "__tests__/goto-anything/slash-command-modes.test.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, "__tests__/i18n-upload-features.test.ts": { "no-console": { "count": 3 @@ -5588,11 +5583,6 @@ "count": 2 } }, - "app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/share/text-generation/run-batch/csv-reader/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 79486b6b4b..419b662b71 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -4,6 +4,17 @@ import viteConfig from './vite.config' const isCI = !!process.env.CI export default mergeConfig(viteConfig, defineConfig({ + plugins: [ + { + // Stub .mdx files so components importing them can be unit-tested + name: 'mdx-stub', + enforce: 'pre', + transform(_, id) { + if (id.endsWith('.mdx')) + return { code: 'export default () => null', map: null } + }, + }, + ], test: { environment: 'jsdom', globals: true, From b65678bd4cd48f72619d2d90ce0578304ef90e79 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 12 Feb 2026 10:28:55 +0800 Subject: [PATCH 07/18] test: add comprehensive unit and integration tests for RAG Pipeline components (#32237) Co-authored-by: CodingOnStar <hanxujiang@dify.com> --- .../chunk-preview-formatting.test.ts | 210 +++++++ .../dsl-export-import-flow.test.ts | 179 ++++++ .../input-field-crud-flow.test.ts | 278 +++++++++ .../input-field-editor-flow.test.ts | 199 +++++++ .../rag-pipeline/test-run-flow.test.ts | 277 +++++++++ .../{ => __tests__}/index.spec.tsx | 68 +-- .../components/__tests__/conversion.spec.tsx | 182 ++++++ .../components/{ => __tests__}/index.spec.tsx | 185 +----- ...blish-as-knowledge-pipeline-modal.spec.tsx | 244 ++++++++ .../{ => __tests__}/publish-toast.spec.tsx | 32 +- .../rag-pipeline-main.spec.tsx | 18 +- .../{ => __tests__}/update-dsl-modal.spec.tsx | 203 +++---- .../version-mismatch-modal.spec.tsx | 2 +- .../__tests__/chunk-card.spec.tsx | 212 +++++++ .../{ => __tests__}/index.spec.tsx | 201 +------ .../panel/{ => __tests__}/index.spec.tsx | 218 +------ .../{ => __tests__}/footer-tip.spec.tsx | 3 +- .../input-field/{ => __tests__}/hooks.spec.ts | 14 +- .../{ => __tests__}/index.spec.tsx | 245 +------- .../editor/{ => __tests__}/index.spec.tsx | 298 +--------- .../editor/form/__tests__/hooks.spec.ts | 366 ++++++++++++ .../form/{ => __tests__}/index.spec.tsx | 357 +----------- .../editor/form/__tests__/schema.spec.ts | 260 +++++++++ .../field-list/__tests__/hooks.spec.ts | 371 ++++++++++++ .../field-list/{ => __tests__}/index.spec.tsx | 435 +------------- .../{ => __tests__}/index.spec.tsx | 23 +- .../preview/{ => __tests__}/index.spec.tsx | 317 +---------- .../test-run/{ => __tests__}/index.spec.tsx | 118 +--- .../preparation/__tests__/hooks.spec.ts | 232 ++++++++ .../{ => __tests__}/index.spec.tsx | 532 +----------------- .../actions/{ => __tests__}/index.spec.tsx | 143 +---- .../{ => __tests__}/index.spec.tsx | 315 +---------- .../{ => __tests__}/index.spec.tsx | 280 +-------- .../result/{ => __tests__}/index.spec.tsx | 159 +----- .../{ => __tests__}/index.spec.tsx | 287 +--------- .../tabs/{ => __tests__}/index.spec.tsx | 232 +------- .../{ => __tests__}/index.spec.tsx | 131 +---- .../__tests__/run-mode.spec.tsx | 192 +++++++ .../publisher/{ => __tests__}/index.spec.tsx | 278 +-------- .../publisher/__tests__/popup.spec.tsx | 319 +++++++++++ .../hooks/{ => __tests__}/index.spec.ts | 42 +- .../hooks/{ => __tests__}/use-DSL.spec.ts | 25 +- .../use-available-nodes-meta-data.spec.ts | 130 +++++ .../hooks/__tests__/use-configs-map.spec.ts | 70 +++ .../use-get-run-and-trace-url.spec.ts | 45 ++ .../__tests__/use-input-field-panel.spec.ts | 130 +++++ .../hooks/__tests__/use-input-fields.spec.ts | 221 ++++++++ .../use-nodes-sync-draft.spec.ts | 26 +- .../use-pipeline-config.spec.ts | 20 +- .../{ => __tests__}/use-pipeline-init.spec.ts | 24 +- .../use-pipeline-refresh-draft.spec.ts | 20 +- .../{ => __tests__}/use-pipeline-run.spec.ts | 41 +- .../use-pipeline-start-run.spec.ts | 18 +- .../__tests__/use-pipeline-template.spec.ts | 61 ++ .../hooks/__tests__/use-pipeline.spec.ts | 321 +++++++++++ .../use-rag-pipeline-search.spec.tsx | 221 ++++++++ .../use-update-dsl-modal.spec.ts | 26 +- .../store/{ => __tests__}/index.spec.ts | 113 ++-- .../utils/{ => __tests__}/index.spec.ts | 17 +- web/eslint-suppressions.json | 10 - 60 files changed, 5025 insertions(+), 5171 deletions(-) create mode 100644 web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts create mode 100644 web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts create mode 100644 web/__tests__/rag-pipeline/input-field-crud-flow.test.ts create mode 100644 web/__tests__/rag-pipeline/input-field-editor-flow.test.ts create mode 100644 web/__tests__/rag-pipeline/test-run-flow.test.ts rename web/app/components/rag-pipeline/{ => __tests__}/index.spec.tsx (86%) create mode 100644 web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx rename web/app/components/rag-pipeline/components/{ => __tests__}/index.spec.tsx (82%) create mode 100644 web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx rename web/app/components/rag-pipeline/components/{ => __tests__}/publish-toast.spec.tsx (69%) rename web/app/components/rag-pipeline/components/{ => __tests__}/rag-pipeline-main.spec.tsx (94%) rename web/app/components/rag-pipeline/components/{ => __tests__}/update-dsl-modal.spec.tsx (77%) rename web/app/components/rag-pipeline/components/{ => __tests__}/version-mismatch-modal.spec.tsx (98%) create mode 100644 web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx rename web/app/components/rag-pipeline/components/chunk-card-list/{ => __tests__}/index.spec.tsx (83%) rename web/app/components/rag-pipeline/components/panel/{ => __tests__}/index.spec.tsx (76%) rename web/app/components/rag-pipeline/components/panel/input-field/{ => __tests__}/footer-tip.spec.tsx (94%) rename web/app/components/rag-pipeline/components/panel/input-field/{ => __tests__}/hooks.spec.ts (87%) rename web/app/components/rag-pipeline/components/panel/input-field/{ => __tests__}/index.spec.tsx (78%) rename web/app/components/rag-pipeline/components/panel/input-field/editor/{ => __tests__}/index.spec.tsx (82%) create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts rename web/app/components/rag-pipeline/components/panel/input-field/editor/form/{ => __tests__}/index.spec.tsx (77%) create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/schema.spec.ts create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts rename web/app/components/rag-pipeline/components/panel/input-field/field-list/{ => __tests__}/index.spec.tsx (80%) rename web/app/components/rag-pipeline/components/panel/input-field/label-right-content/{ => __tests__}/index.spec.tsx (90%) rename web/app/components/rag-pipeline/components/panel/input-field/preview/{ => __tests__}/index.spec.tsx (75%) rename web/app/components/rag-pipeline/components/panel/test-run/{ => __tests__}/index.spec.tsx (85%) create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/hooks.spec.ts rename web/app/components/rag-pipeline/components/panel/test-run/preparation/{ => __tests__}/index.spec.tsx (76%) rename web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/{ => __tests__}/index.spec.tsx (74%) rename web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/{ => __tests__}/index.spec.tsx (80%) rename web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/rag-pipeline/components/panel/test-run/result/{ => __tests__}/index.spec.tsx (81%) rename web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/{ => __tests__}/index.spec.tsx (79%) rename web/app/components/rag-pipeline/components/panel/test-run/result/tabs/{ => __tests__}/index.spec.tsx (81%) rename web/app/components/rag-pipeline/components/rag-pipeline-header/{ => __tests__}/index.spec.tsx (84%) create mode 100644 web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx rename web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/{ => __tests__}/index.spec.tsx (81%) create mode 100644 web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx rename web/app/components/rag-pipeline/hooks/{ => __tests__}/index.spec.ts (91%) rename web/app/components/rag-pipeline/hooks/{ => __tests__}/use-DSL.spec.ts (90%) create mode 100644 web/app/components/rag-pipeline/hooks/__tests__/use-available-nodes-meta-data.spec.ts create mode 100644 web/app/components/rag-pipeline/hooks/__tests__/use-configs-map.spec.ts create mode 100644 web/app/components/rag-pipeline/hooks/__tests__/use-get-run-and-trace-url.spec.ts create mode 100644 web/app/components/rag-pipeline/hooks/__tests__/use-input-field-panel.spec.ts create mode 100644 web/app/components/rag-pipeline/hooks/__tests__/use-input-fields.spec.ts rename web/app/components/rag-pipeline/hooks/{ => __tests__}/use-nodes-sync-draft.spec.ts (93%) rename web/app/components/rag-pipeline/hooks/{ => __tests__}/use-pipeline-config.spec.ts (92%) rename web/app/components/rag-pipeline/hooks/{ => __tests__}/use-pipeline-init.spec.ts (92%) rename web/app/components/rag-pipeline/hooks/{ => __tests__}/use-pipeline-refresh-draft.spec.ts (90%) rename web/app/components/rag-pipeline/hooks/{ => __tests__}/use-pipeline-run.spec.ts (95%) rename web/app/components/rag-pipeline/hooks/{ => __tests__}/use-pipeline-start-run.spec.ts (90%) create mode 100644 web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-template.spec.ts create mode 100644 web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts create mode 100644 web/app/components/rag-pipeline/hooks/__tests__/use-rag-pipeline-search.spec.tsx rename web/app/components/rag-pipeline/hooks/{ => __tests__}/use-update-dsl-modal.spec.ts (94%) rename web/app/components/rag-pipeline/store/{ => __tests__}/index.spec.ts (65%) rename web/app/components/rag-pipeline/utils/{ => __tests__}/index.spec.ts (93%) diff --git a/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts new file mode 100644 index 0000000000..c4cafbc1c5 --- /dev/null +++ b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts @@ -0,0 +1,210 @@ +/** + * Integration test: Chunk preview formatting pipeline + * + * Tests the formatPreviewChunks utility across all chunking modes + * (text, parentChild, QA) with real data structures. + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/config', () => ({ + RAG_PIPELINE_PREVIEW_CHUNK_NUM: 3, +})) + +vi.mock('@/models/datasets', () => ({ + ChunkingMode: { + text: 'text', + parentChild: 'parent-child', + qa: 'qa', + }, +})) + +const { formatPreviewChunks } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils', +) + +describe('Chunk Preview Formatting', () => { + describe('general text chunks', () => { + it('should format text chunks correctly', () => { + const outputs = { + chunk_structure: 'text', + preview: [ + { content: 'Chunk 1 content', summary: 'Summary 1' }, + { content: 'Chunk 2 content' }, + ], + } + + const result = formatPreviewChunks(outputs) + + expect(Array.isArray(result)).toBe(true) + const chunks = result as Array<{ content: string, summary?: string }> + expect(chunks).toHaveLength(2) + expect(chunks[0].content).toBe('Chunk 1 content') + expect(chunks[0].summary).toBe('Summary 1') + expect(chunks[1].content).toBe('Chunk 2 content') + }) + + it('should limit chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM', () => { + const outputs = { + chunk_structure: 'text', + preview: Array.from({ length: 10 }, (_, i) => ({ + content: `Chunk ${i + 1}`, + })), + } + + const result = formatPreviewChunks(outputs) + const chunks = result as Array<{ content: string }> + + expect(chunks).toHaveLength(3) // Mocked limit + }) + }) + + describe('parent-child chunks — paragraph mode', () => { + it('should format paragraph parent-child chunks', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'paragraph', + preview: [ + { + content: 'Parent paragraph', + child_chunks: ['Child 1', 'Child 2'], + summary: 'Parent summary', + }, + ], + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: Array<{ + parent_content: string + parent_summary?: string + child_contents: string[] + parent_mode: string + }> + parent_mode: string + } + + expect(result.parent_mode).toBe('paragraph') + expect(result.parent_child_chunks).toHaveLength(1) + expect(result.parent_child_chunks[0].parent_content).toBe('Parent paragraph') + expect(result.parent_child_chunks[0].parent_summary).toBe('Parent summary') + expect(result.parent_child_chunks[0].child_contents).toEqual(['Child 1', 'Child 2']) + }) + + it('should limit parent chunks in paragraph mode', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'paragraph', + preview: Array.from({ length: 10 }, (_, i) => ({ + content: `Parent ${i + 1}`, + child_chunks: [`Child of ${i + 1}`], + })), + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: unknown[] + } + + expect(result.parent_child_chunks).toHaveLength(3) // Mocked limit + }) + }) + + describe('parent-child chunks — full-doc mode', () => { + it('should format full-doc parent-child chunks', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'full-doc', + preview: [ + { + content: 'Full document content', + child_chunks: ['Section 1', 'Section 2', 'Section 3'], + }, + ], + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: Array<{ + parent_content: string + child_contents: string[] + parent_mode: string + }> + } + + expect(result.parent_child_chunks).toHaveLength(1) + expect(result.parent_child_chunks[0].parent_content).toBe('Full document content') + expect(result.parent_child_chunks[0].parent_mode).toBe('full-doc') + }) + + it('should limit child chunks in full-doc mode', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'full-doc', + preview: [ + { + content: 'Document', + child_chunks: Array.from({ length: 20 }, (_, i) => `Section ${i + 1}`), + }, + ], + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: Array<{ child_contents: string[] }> + } + + expect(result.parent_child_chunks[0].child_contents).toHaveLength(3) // Mocked limit + }) + }) + + describe('QA chunks', () => { + it('should format QA chunks correctly', () => { + const outputs = { + chunk_structure: 'qa', + qa_preview: [ + { question: 'What is AI?', answer: 'Artificial Intelligence is...' }, + { question: 'What is ML?', answer: 'Machine Learning is...' }, + ], + } + + const result = formatPreviewChunks(outputs) as { + qa_chunks: Array<{ question: string, answer: string }> + } + + expect(result.qa_chunks).toHaveLength(2) + expect(result.qa_chunks[0].question).toBe('What is AI?') + expect(result.qa_chunks[0].answer).toBe('Artificial Intelligence is...') + }) + + it('should limit QA chunks', () => { + const outputs = { + chunk_structure: 'qa', + qa_preview: Array.from({ length: 10 }, (_, i) => ({ + question: `Q${i + 1}`, + answer: `A${i + 1}`, + })), + } + + const result = formatPreviewChunks(outputs) as { + qa_chunks: unknown[] + } + + expect(result.qa_chunks).toHaveLength(3) // Mocked limit + }) + }) + + describe('edge cases', () => { + it('should return undefined for null outputs', () => { + expect(formatPreviewChunks(null)).toBeUndefined() + }) + + it('should return undefined for undefined outputs', () => { + expect(formatPreviewChunks(undefined)).toBeUndefined() + }) + + it('should return undefined for unknown chunk_structure', () => { + const outputs = { + chunk_structure: 'unknown-type', + preview: [], + } + + expect(formatPreviewChunks(outputs)).toBeUndefined() + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts new file mode 100644 index 0000000000..578552840d --- /dev/null +++ b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts @@ -0,0 +1,179 @@ +/** + * Integration test: DSL export/import flow + * + * Validates DSL export logic (sync draft → check secrets → download) + * and DSL import modal state management. + */ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined) +const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' }) +const mockNotify = vi.fn() +const mockEventEmitter = { emit: vi.fn() } +const mockDownloadBlob = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: mockNotify }), +})) + +vi.mock('@/app/components/workflow/constants', () => ({ + DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + pipelineId: 'pipeline-abc', + knowledgeName: 'My Pipeline', + }), + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: mockEventEmitter, + }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useExportPipelineDSL: () => ({ + mutateAsync: mockExportPipelineConfig, + }), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: vi.fn(), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: mockDoSyncWorkflowDraft, + }), +})) + +describe('DSL Export/Import Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Export Flow', () => { + it('should sync draft then export then download', async () => { + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL() + }) + + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mockExportPipelineConfig).toHaveBeenCalledWith({ + pipelineId: 'pipeline-abc', + include: false, + }) + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ + fileName: 'My Pipeline.pipeline', + })) + }) + + it('should export with include flag when specified', async () => { + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL(true) + }) + + expect(mockExportPipelineConfig).toHaveBeenCalledWith({ + pipelineId: 'pipeline-abc', + include: true, + }) + }) + + it('should notify on export error', async () => { + mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed')) + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) + + describe('Export Check Flow', () => { + it('should export directly when no secret environment variables', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({ + environment_variables: [ + { value_type: 'string', key: 'API_URL', value: 'https://api.example.com' }, + ], + } as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + // Should proceed to export directly (no secret vars) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + }) + + it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({ + environment_variables: [ + { value_type: 'secret', key: 'API_KEY', value: '***' }, + ], + } as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'DSL_EXPORT_CHECK', + payload: expect.objectContaining({ + data: expect.arrayContaining([ + expect.objectContaining({ value_type: 'secret' }), + ]), + }), + })) + }) + + it('should notify on export check error', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed')) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts new file mode 100644 index 0000000000..233c9a288a --- /dev/null +++ b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts @@ -0,0 +1,278 @@ +/** + * Integration test: Input field CRUD complete flow + * + * Validates the full lifecycle of input fields: + * creation, editing, renaming, removal, and data conversion round-trip. + */ +import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types' +import type { InputVar } from '@/models/pipeline' +import { describe, expect, it, vi } from 'vitest' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { PipelineInputVarType } from '@/models/pipeline' +import { TransferMethod } from '@/types/app' + +vi.mock('@/config', () => ({ + VAR_ITEM_TEMPLATE_IN_PIPELINE: { + type: 'text-input', + label: '', + variable: '', + max_length: 48, + default_value: undefined, + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, +})) + +describe('Input Field CRUD Flow', () => { + describe('Create → Edit → Convert Round-trip', () => { + it('should create a text field and roundtrip through form data', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + // Create new field from template (no data passed) + const newFormData = convertToInputFieldFormData() + expect(newFormData.type).toBe('text-input') + expect(newFormData.variable).toBe('') + expect(newFormData.label).toBe('') + expect(newFormData.required).toBe(true) + + // Simulate user editing form data + const editedFormData: FormData = { + ...newFormData, + variable: 'user_name', + label: 'User Name', + maxLength: 100, + default: 'John', + tooltips: 'Enter your name', + placeholder: 'Type here...', + allowedTypesAndExtensions: {}, + } + + // Convert back to InputVar + const inputVar = convertFormDataToINputField(editedFormData) + + expect(inputVar.variable).toBe('user_name') + expect(inputVar.label).toBe('User Name') + expect(inputVar.max_length).toBe(100) + expect(inputVar.default_value).toBe('John') + expect(inputVar.tooltips).toBe('Enter your name') + expect(inputVar.placeholder).toBe('Type here...') + expect(inputVar.required).toBe(true) + }) + + it('should handle file field with upload settings', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const fileInputVar: InputVar = { + type: PipelineInputVarType.singleFile, + label: 'Upload Document', + variable: 'doc_file', + max_length: 1, + default_value: undefined, + required: true, + tooltips: 'Upload a PDF', + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['.pdf', '.docx'], + } + + // Convert to form data + const formData = convertToInputFieldFormData(fileInputVar) + expect(formData.allowedFileUploadMethods).toEqual([TransferMethod.local_file, TransferMethod.remote_url]) + expect(formData.allowedTypesAndExtensions).toEqual({ + allowedFileTypes: [SupportUploadFileTypes.document], + allowedFileExtensions: ['.pdf', '.docx'], + }) + + // Round-trip back + const restored = convertFormDataToINputField(formData) + expect(restored.allowed_file_upload_methods).toEqual([TransferMethod.local_file, TransferMethod.remote_url]) + expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document]) + expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx']) + }) + + it('should handle select field with options', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const selectVar: InputVar = { + type: PipelineInputVarType.select, + label: 'Priority', + variable: 'priority', + max_length: 0, + default_value: 'medium', + required: false, + tooltips: 'Select priority level', + options: ['low', 'medium', 'high'], + placeholder: 'Choose...', + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(selectVar) + expect(formData.options).toEqual(['low', 'medium', 'high']) + expect(formData.default).toBe('medium') + + const restored = convertFormDataToINputField(formData) + expect(restored.options).toEqual(['low', 'medium', 'high']) + expect(restored.default_value).toBe('medium') + }) + + it('should handle number field with unit', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const numberVar: InputVar = { + type: PipelineInputVarType.number, + label: 'Max Tokens', + variable: 'max_tokens', + max_length: 0, + default_value: '1024', + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: 'tokens', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(numberVar) + expect(formData.unit).toBe('tokens') + expect(formData.default).toBe('1024') + + const restored = convertFormDataToINputField(formData) + expect(restored.unit).toBe('tokens') + expect(restored.default_value).toBe('1024') + }) + }) + + describe('Omit optional fields', () => { + it('should not include tooltips when undefined', async () => { + const { convertToInputFieldFormData } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const inputVar: InputVar = { + type: PipelineInputVarType.textInput, + label: 'Test', + variable: 'test', + max_length: 48, + default_value: undefined, + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(inputVar) + + // Optional fields should not be present + expect('tooltips' in formData).toBe(false) + expect('placeholder' in formData).toBe(false) + expect('unit' in formData).toBe(false) + expect('default' in formData).toBe(false) + }) + + it('should include optional fields when explicitly set to empty string', async () => { + const { convertToInputFieldFormData } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const inputVar: InputVar = { + type: PipelineInputVarType.textInput, + label: 'Test', + variable: 'test', + max_length: 48, + default_value: '', + required: true, + tooltips: '', + options: [], + placeholder: '', + unit: '', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.default).toBe('') + expect(formData.tooltips).toBe('') + expect(formData.placeholder).toBe('') + expect(formData.unit).toBe('') + }) + }) + + describe('Multiple fields workflow', () => { + it('should process multiple fields independently', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const fields: InputVar[] = [ + { + type: PipelineInputVarType.textInput, + label: 'Name', + variable: 'name', + max_length: 48, + default_value: 'Alice', + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, + { + type: PipelineInputVarType.number, + label: 'Count', + variable: 'count', + max_length: 0, + default_value: '10', + required: false, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: 'items', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, + ] + + const formDataList = fields.map(f => convertToInputFieldFormData(f)) + const restoredFields = formDataList.map(fd => convertFormDataToINputField(fd)) + + expect(restoredFields).toHaveLength(2) + expect(restoredFields[0].variable).toBe('name') + expect(restoredFields[0].default_value).toBe('Alice') + expect(restoredFields[1].variable).toBe('count') + expect(restoredFields[1].default_value).toBe('10') + expect(restoredFields[1].unit).toBe('items') + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts new file mode 100644 index 0000000000..0fc4699aa8 --- /dev/null +++ b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts @@ -0,0 +1,199 @@ +/** + * Integration test: Input field editor data conversion flow + * + * Tests the full pipeline: InputVar -> FormData -> InputVar roundtrip + * and schema validation for various input types. + */ +import type { InputVar } from '@/models/pipeline' +import { describe, expect, it, vi } from 'vitest' +import { PipelineInputVarType } from '@/models/pipeline' + +// Mock the config module for VAR_ITEM_TEMPLATE_IN_PIPELINE +vi.mock('@/config', () => ({ + VAR_ITEM_TEMPLATE_IN_PIPELINE: { + type: 'text-input', + label: '', + variable: '', + max_length: 48, + required: false, + options: [], + allowed_file_upload_methods: [], + allowed_file_types: [], + allowed_file_extensions: [], + }, + MAX_VAR_KEY_LENGTH: 30, + RAG_PIPELINE_PREVIEW_CHUNK_NUM: 10, +})) + +// Import real functions (not mocked) +const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', +) + +describe('Input Field Editor Data Flow', () => { + describe('convertToInputFieldFormData', () => { + it('should convert a text input InputVar to FormData', () => { + const inputVar: InputVar = { + type: 'text-input', + label: 'Name', + variable: 'user_name', + max_length: 100, + required: true, + default_value: 'John', + tooltips: 'Enter your name', + placeholder: 'Type here...', + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.type).toBe('text-input') + expect(formData.label).toBe('Name') + expect(formData.variable).toBe('user_name') + expect(formData.maxLength).toBe(100) + expect(formData.required).toBe(true) + expect(formData.default).toBe('John') + expect(formData.tooltips).toBe('Enter your name') + expect(formData.placeholder).toBe('Type here...') + }) + + it('should handle file input with upload settings', () => { + const inputVar: InputVar = { + type: 'file', + label: 'Document', + variable: 'doc', + required: false, + allowed_file_upload_methods: ['local_file', 'remote_url'], + allowed_file_types: ['document', 'image'], + allowed_file_extensions: ['.pdf', '.jpg'], + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.allowedFileUploadMethods).toEqual(['local_file', 'remote_url']) + expect(formData.allowedTypesAndExtensions).toEqual({ + allowedFileTypes: ['document', 'image'], + allowedFileExtensions: ['.pdf', '.jpg'], + }) + }) + + it('should use template defaults when no data provided', () => { + const formData = convertToInputFieldFormData(undefined) + + expect(formData.type).toBe('text-input') + expect(formData.maxLength).toBe(48) + expect(formData.required).toBe(false) + }) + + it('should omit undefined/null optional fields', () => { + const inputVar: InputVar = { + type: 'text-input', + label: 'Simple', + variable: 'simple_var', + max_length: 50, + required: false, + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.default).toBeUndefined() + expect(formData.tooltips).toBeUndefined() + expect(formData.placeholder).toBeUndefined() + expect(formData.unit).toBeUndefined() + }) + }) + + describe('convertFormDataToINputField', () => { + it('should convert FormData back to InputVar', () => { + const formData = { + type: PipelineInputVarType.textInput, + label: 'Name', + variable: 'user_name', + maxLength: 100, + required: true, + default: 'John', + tooltips: 'Enter your name', + options: [], + placeholder: 'Type here...', + allowedTypesAndExtensions: { + allowedFileTypes: undefined, + allowedFileExtensions: undefined, + }, + } + + const inputVar = convertFormDataToINputField(formData) + + expect(inputVar.type).toBe('text-input') + expect(inputVar.label).toBe('Name') + expect(inputVar.variable).toBe('user_name') + expect(inputVar.max_length).toBe(100) + expect(inputVar.required).toBe(true) + expect(inputVar.default_value).toBe('John') + expect(inputVar.tooltips).toBe('Enter your name') + }) + }) + + describe('roundtrip conversion', () => { + it('should preserve text input data through roundtrip', () => { + const original: InputVar = { + type: 'text-input', + label: 'Question', + variable: 'question', + max_length: 200, + required: true, + default_value: 'What is AI?', + tooltips: 'Enter your question', + placeholder: 'Ask something...', + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(original) + const restored = convertFormDataToINputField(formData) + + expect(restored.type).toBe(original.type) + expect(restored.label).toBe(original.label) + expect(restored.variable).toBe(original.variable) + expect(restored.max_length).toBe(original.max_length) + expect(restored.required).toBe(original.required) + expect(restored.default_value).toBe(original.default_value) + expect(restored.tooltips).toBe(original.tooltips) + expect(restored.placeholder).toBe(original.placeholder) + }) + + it('should preserve number input data through roundtrip', () => { + const original = { + type: 'number', + label: 'Temperature', + variable: 'temp', + required: false, + default_value: '0.7', + unit: '°C', + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(original) + const restored = convertFormDataToINputField(formData) + + expect(restored.type).toBe('number') + expect(restored.unit).toBe('°C') + expect(restored.default_value).toBe('0.7') + }) + + it('should preserve select options through roundtrip', () => { + const original: InputVar = { + type: 'select', + label: 'Mode', + variable: 'mode', + required: true, + options: ['fast', 'balanced', 'quality'], + } as InputVar + + const formData = convertToInputFieldFormData(original) + const restored = convertFormDataToINputField(formData) + + expect(restored.options).toEqual(['fast', 'balanced', 'quality']) + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/test-run-flow.test.ts b/web/__tests__/rag-pipeline/test-run-flow.test.ts new file mode 100644 index 0000000000..a2bf557acd --- /dev/null +++ b/web/__tests__/rag-pipeline/test-run-flow.test.ts @@ -0,0 +1,277 @@ +/** + * Integration test: Test run end-to-end flow + * + * Validates the data flow through test-run preparation hooks: + * step navigation, datasource filtering, and data clearing. + */ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { BlockEnum } from '@/app/components/workflow/types' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mutable holder so mock data can reference BlockEnum after imports +const mockNodesHolder = vi.hoisted(() => ({ value: [] as Record<string, unknown>[] })) + +vi.mock('reactflow', () => ({ + useNodes: () => mockNodesHolder.value, +})) + +mockNodesHolder.value = [ + { + id: 'ds-1', + data: { + type: BlockEnum.DataSource, + title: 'Local Files', + datasource_type: 'upload_file', + datasource_configurations: { datasource_label: 'Upload', upload_file_config: {} }, + }, + }, + { + id: 'ds-2', + data: { + type: BlockEnum.DataSource, + title: 'Web Crawl', + datasource_type: 'website_crawl', + datasource_configurations: { datasource_label: 'Crawl' }, + }, + }, + { + id: 'kb-1', + data: { + type: BlockEnum.KnowledgeBase, + title: 'Knowledge Base', + }, + }, +] + +// Mock the Zustand store used by the hooks +const mockSetDocumentsData = vi.fn() +const mockSetSearchValue = vi.fn() +const mockSetSelectedPagesId = vi.fn() +const mockSetOnlineDocuments = vi.fn() +const mockSetCurrentDocument = vi.fn() +const mockSetStep = vi.fn() +const mockSetCrawlResult = vi.fn() +const mockSetWebsitePages = vi.fn() +const mockSetPreviewIndex = vi.fn() +const mockSetCurrentWebsite = vi.fn() +const mockSetOnlineDriveFileList = vi.fn() +const mockSetBucket = vi.fn() +const mockSetPrefix = vi.fn() +const mockSetKeywords = vi.fn() +const mockSetSelectedFileIds = vi.fn() + +vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({ + useDataSourceStore: () => ({ + getState: () => ({ + setDocumentsData: mockSetDocumentsData, + setSearchValue: mockSetSearchValue, + setSelectedPagesId: mockSetSelectedPagesId, + setOnlineDocuments: mockSetOnlineDocuments, + setCurrentDocument: mockSetCurrentDocument, + setStep: mockSetStep, + setCrawlResult: mockSetCrawlResult, + setWebsitePages: mockSetWebsitePages, + setPreviewIndex: mockSetPreviewIndex, + setCurrentWebsite: mockSetCurrentWebsite, + setOnlineDriveFileList: mockSetOnlineDriveFileList, + setBucket: mockSetBucket, + setPrefix: mockSetPrefix, + setKeywords: mockSetKeywords, + setSelectedFileIds: mockSetSelectedFileIds, + }), + }), +})) + +vi.mock('@/models/datasets', () => ({ + CrawlStep: { + init: 'init', + }, +})) + +describe('Test Run Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Step Navigation', () => { + it('should start at step 1 and navigate forward', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.currentStep).toBe(1) + + act(() => { + result.current.handleNextStep() + }) + + expect(result.current.currentStep).toBe(2) + }) + + it('should navigate back from step 2 to step 1', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(1) + }) + + it('should provide labeled steps', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.steps).toHaveLength(2) + expect(result.current.steps[0].value).toBe('dataSource') + expect(result.current.steps[1].value).toBe('documentProcessing') + }) + }) + + describe('Datasource Options', () => { + it('should filter nodes to only DataSource type', async () => { + const { useDatasourceOptions } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useDatasourceOptions()) + + // Should only include DataSource nodes, not KnowledgeBase + expect(result.current).toHaveLength(2) + expect(result.current[0].value).toBe('ds-1') + expect(result.current[1].value).toBe('ds-2') + }) + + it('should include node data in options', async () => { + const { useDatasourceOptions } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useDatasourceOptions()) + + expect(result.current[0].label).toBe('Local Files') + expect(result.current[0].data.type).toBe(BlockEnum.DataSource) + }) + }) + + describe('Data Clearing Flow', () => { + it('should clear online document data', async () => { + const { useOnlineDocument } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useOnlineDocument()) + + act(() => { + result.current.clearOnlineDocumentData() + }) + + expect(mockSetDocumentsData).toHaveBeenCalledWith([]) + expect(mockSetSearchValue).toHaveBeenCalledWith('') + expect(mockSetSelectedPagesId).toHaveBeenCalledWith(expect.any(Set)) + expect(mockSetOnlineDocuments).toHaveBeenCalledWith([]) + expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined) + }) + + it('should clear website crawl data', async () => { + const { useWebsiteCrawl } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useWebsiteCrawl()) + + act(() => { + result.current.clearWebsiteCrawlData() + }) + + expect(mockSetStep).toHaveBeenCalledWith('init') + expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined) + expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined) + expect(mockSetWebsitePages).toHaveBeenCalledWith([]) + expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1) + }) + + it('should clear online drive data', async () => { + const { useOnlineDrive } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useOnlineDrive()) + + act(() => { + result.current.clearOnlineDriveData() + }) + + expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockSetBucket).toHaveBeenCalledWith('') + expect(mockSetPrefix).toHaveBeenCalledWith([]) + expect(mockSetKeywords).toHaveBeenCalledWith('') + expect(mockSetSelectedFileIds).toHaveBeenCalledWith([]) + }) + }) + + describe('Full Flow Simulation', () => { + it('should support complete step navigation cycle', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + // Start at step 1 + expect(result.current.currentStep).toBe(1) + + // Move to step 2 + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + + // Go back to step 1 + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(1) + + // Move forward again + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + }) + + it('should not regress when clearing all data sources in sequence', async () => { + const { + useOnlineDocument, + useWebsiteCrawl, + useOnlineDrive, + } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result: docResult } = renderHook(() => useOnlineDocument()) + const { result: crawlResult } = renderHook(() => useWebsiteCrawl()) + const { result: driveResult } = renderHook(() => useOnlineDrive()) + + // Clear all data sources + act(() => { + docResult.current.clearOnlineDocumentData() + crawlResult.current.clearWebsiteCrawlData() + driveResult.current.clearOnlineDriveData() + }) + + expect(mockSetDocumentsData).toHaveBeenCalledWith([]) + expect(mockSetStep).toHaveBeenCalledWith('init') + expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/index.spec.tsx b/web/app/components/rag-pipeline/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/rag-pipeline/index.spec.tsx rename to web/app/components/rag-pipeline/__tests__/index.spec.tsx index 5adfc828cf..221713defe 100644 --- a/web/app/components/rag-pipeline/index.spec.tsx +++ b/web/app/components/rag-pipeline/__tests__/index.spec.tsx @@ -3,45 +3,36 @@ import { cleanup, render, screen } from '@testing-library/react' import * as React from 'react' import { BlockEnum } from '@/app/components/workflow/types' -// Import real utility functions (pure functions, no side effects) - -// Import mocked modules for manipulation import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { usePipelineInit } from './hooks' -import RagPipelineWrapper from './index' -import { processNodesWithoutDataSource } from './utils' +import { usePipelineInit } from '../hooks' +import RagPipelineWrapper from '../index' +import { processNodesWithoutDataSource } from '../utils' -// Mock: Context - need to control return values vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: vi.fn(), })) -// Mock: Hook with API calls -vi.mock('./hooks', () => ({ +vi.mock('../hooks', () => ({ usePipelineInit: vi.fn(), })) -// Mock: Store creator -vi.mock('./store', () => ({ +vi.mock('../store', () => ({ createRagPipelineSliceSlice: vi.fn(() => ({})), })) -// Mock: Utility with complex workflow dependencies (generateNewNode, etc.) -vi.mock('./utils', () => ({ +vi.mock('../utils', () => ({ processNodesWithoutDataSource: vi.fn((nodes, viewport) => ({ nodes, viewport, })), })) -// Mock: Complex component with useParams, Toast, API calls -vi.mock('./components/conversion', () => ({ +vi.mock('../components/conversion', () => ({ default: () => <div data-testid="conversion-component">Conversion Component</div>, })) -// Mock: Complex component with many hooks and workflow dependencies -vi.mock('./components/rag-pipeline-main', () => ({ - default: ({ nodes, edges, viewport }: any) => ( +vi.mock('../components/rag-pipeline-main', () => ({ + default: ({ nodes, edges, viewport }: { nodes?: unknown[], edges?: unknown[], viewport?: { zoom?: number } }) => ( <div data-testid="rag-pipeline-main"> <span data-testid="nodes-count">{nodes?.length ?? 0}</span> <span data-testid="edges-count">{edges?.length ?? 0}</span> @@ -50,35 +41,29 @@ vi.mock('./components/rag-pipeline-main', () => ({ ), })) -// Mock: Complex component with ReactFlow and many providers vi.mock('@/app/components/workflow', () => ({ default: ({ children }: { children: React.ReactNode }) => ( <div data-testid="workflow-default-context">{children}</div> ), })) -// Mock: Context provider vi.mock('@/app/components/workflow/context', () => ({ WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => ( <div data-testid="workflow-context-provider">{children}</div> ), })) -// Type assertions for mocked functions const mockUseDatasetDetailContextWithSelector = vi.mocked(useDatasetDetailContextWithSelector) const mockUsePipelineInit = vi.mocked(usePipelineInit) const mockProcessNodesWithoutDataSource = vi.mocked(processNodesWithoutDataSource) -// Helper to mock selector with actual execution (increases function coverage) -// This executes the real selector function: s => s.dataset?.pipeline_id const mockSelectorWithDataset = (pipelineId: string | null | undefined) => { - mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: any) => any) => { + mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => { const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null } return selector(mockState) }) } -// Test data factory const createMockWorkflowData = (overrides?: Partial<FetchWorkflowDraftResponse>): FetchWorkflowDraftResponse => ({ graph: { nodes: [ @@ -157,7 +142,6 @@ describe('RagPipelineWrapper', () => { describe('RagPipeline', () => { beforeEach(() => { - // Default setup for RagPipeline tests - execute real selector function mockSelectorWithDataset('pipeline-123') }) @@ -167,7 +151,6 @@ describe('RagPipeline', () => { render(<RagPipelineWrapper />) - // Real Loading component has role="status" expect(screen.getByRole('status')).toBeInTheDocument() }) @@ -240,8 +223,6 @@ describe('RagPipeline', () => { render(<RagPipelineWrapper />) - // initialNodes is a real function - verify nodes are rendered - // The real initialNodes processes nodes and adds position data expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument() }) @@ -251,7 +232,6 @@ describe('RagPipeline', () => { render(<RagPipelineWrapper />) - // initialEdges is a real function - verify component renders with edges expect(screen.getByTestId('edges-count').textContent).toBe('1') }) @@ -269,7 +249,6 @@ describe('RagPipeline', () => { render(<RagPipelineWrapper />) - // When data is undefined, Loading is shown, processNodesWithoutDataSource is not called expect(mockProcessNodesWithoutDataSource).not.toHaveBeenCalled() }) @@ -279,13 +258,10 @@ describe('RagPipeline', () => { const { rerender } = render(<RagPipelineWrapper />) - // Clear mock call count after initial render mockProcessNodesWithoutDataSource.mockClear() - // Rerender with same data reference (no change to mockUsePipelineInit) rerender(<RagPipelineWrapper />) - // processNodesWithoutDataSource should not be called again due to useMemo // Note: React strict mode may cause double render, so we check it's not excessive expect(mockProcessNodesWithoutDataSource.mock.calls.length).toBeLessThanOrEqual(1) }) @@ -327,7 +303,7 @@ describe('RagPipeline', () => { graph: { nodes: [], edges: [], - viewport: undefined as any, + viewport: undefined as never, }, }) mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) @@ -342,7 +318,7 @@ describe('RagPipeline', () => { graph: { nodes: [], edges: [], - viewport: null as any, + viewport: null as never, }, }) mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) @@ -438,7 +414,7 @@ describe('processNodesWithoutDataSource utility integration', () => { const mockData = createMockWorkflowData() mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) mockProcessNodesWithoutDataSource.mockReturnValue({ - nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as any, + nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as unknown as ReturnType<typeof processNodesWithoutDataSource>['nodes'], viewport: { x: 0, y: 0, zoom: 2 }, }) @@ -467,14 +443,11 @@ describe('Conditional Rendering Flow', () => { it('should transition from loading to loaded state', () => { mockSelectorWithDataset('pipeline-123') - // Start with loading state mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true }) const { rerender } = render(<RagPipelineWrapper />) - // Real Loading component has role="status" expect(screen.getByRole('status')).toBeInTheDocument() - // Transition to loaded state const mockData = createMockWorkflowData() mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) rerender(<RagPipelineWrapper />) @@ -483,7 +456,6 @@ describe('Conditional Rendering Flow', () => { }) it('should switch from Conversion to Pipeline when pipelineId becomes available', () => { - // Start without pipelineId mockSelectorWithDataset(null) mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false }) @@ -491,13 +463,11 @@ describe('Conditional Rendering Flow', () => { expect(screen.getByTestId('conversion-component')).toBeInTheDocument() - // PipelineId becomes available mockSelectorWithDataset('new-pipeline-id') mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true }) rerender(<RagPipelineWrapper />) expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument() - // Real Loading component has role="status" expect(screen.getByRole('status')).toBeInTheDocument() }) }) @@ -510,21 +480,18 @@ describe('Error Handling', () => { it('should throw when graph nodes is null', () => { const mockData = { graph: { - nodes: null as any, - edges: null as any, + nodes: null, + edges: null, viewport: { x: 0, y: 0, zoom: 1 }, }, hash: 'test', updated_at: 123, - } as FetchWorkflowDraftResponse + } as unknown as FetchWorkflowDraftResponse mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) - // Suppress console.error for expected error const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - // Real initialNodes will throw when nodes is null - // This documents the component's current behavior - it requires valid nodes array expect(() => render(<RagPipelineWrapper />)).toThrow() consoleSpy.mockRestore() @@ -538,11 +505,8 @@ describe('Error Handling', () => { mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) - // Suppress console.error for expected error const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - // When graph is undefined, component throws because data.graph.nodes is accessed - // This documents the component's current behavior - it requires graph to be present expect(() => render(<RagPipelineWrapper />)).toThrow() consoleSpy.mockRestore() diff --git a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx new file mode 100644 index 0000000000..2bd20fb5c3 --- /dev/null +++ b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx @@ -0,0 +1,182 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import Conversion from '../conversion' + +const mockConvert = vi.fn() +const mockInvalidDatasetDetail = vi.fn() +vi.mock('next/navigation', () => ({ + useParams: () => ({ datasetId: 'ds-123' }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useConvertDatasetToPipeline: () => ({ + mutateAsync: mockConvert, + isPending: false, + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + datasetDetailQueryKeyPrefix: ['dataset-detail'], +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: () => mockInvalidDatasetDetail, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, ...props }: Record<string, unknown>) => ( + <button onClick={onClick as () => void} {...props}>{children as string}</button> + ), +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ + isShow, + onConfirm, + onCancel, + title, + }: { + isShow: boolean + onConfirm: () => void + onCancel: () => void + title: string + }) => + isShow + ? ( + <div data-testid="confirm-modal"> + <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('../screenshot', () => ({ + default: () => <div data-testid="screenshot" />, +})) + +describe('Conversion', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should render conversion title and description', () => { + render(<Conversion />) + + expect(screen.getByText('datasetPipeline.conversion.title')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.conversion.descriptionChunk1')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.conversion.descriptionChunk2')).toBeInTheDocument() + }) + + it('should render convert button', () => { + render(<Conversion />) + + expect(screen.getByText('datasetPipeline.operations.convert')).toBeInTheDocument() + }) + + it('should render warning text', () => { + render(<Conversion />) + + expect(screen.getByText('datasetPipeline.conversion.warning')).toBeInTheDocument() + }) + + it('should render screenshot component', () => { + render(<Conversion />) + + expect(screen.getByTestId('screenshot')).toBeInTheDocument() + }) + + it('should show confirm modal when convert button clicked', () => { + render(<Conversion />) + + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument() + }) + + it('should hide confirm modal when cancel is clicked', () => { + render(<Conversion />) + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('cancel-btn')) + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) + + it('should call convert when confirm is clicked', () => { + render(<Conversion />) + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(mockConvert).toHaveBeenCalledWith('ds-123', expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + }) + + it('should handle successful conversion', async () => { + const Toast = await import('@/app/components/base/toast') + mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { + opts.onSuccess({ status: 'success' }) + }) + + render(<Conversion />) + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + expect(mockInvalidDatasetDetail).toHaveBeenCalled() + }) + + it('should handle failed conversion', async () => { + const Toast = await import('@/app/components/base/toast') + mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { + opts.onSuccess({ status: 'failed' }) + }) + + render(<Conversion />) + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + it('should handle conversion error', async () => { + const Toast = await import('@/app/components/base/toast') + mockConvert.mockImplementation((_id: string, opts: { onError: () => void }) => { + opts.onError() + }) + + render(<Conversion />) + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) +}) diff --git a/web/app/components/rag-pipeline/components/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx similarity index 82% rename from web/app/components/rag-pipeline/components/index.spec.tsx rename to web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index e17f07303d..5c3781e8c1 100644 --- a/web/app/components/rag-pipeline/components/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -3,29 +3,19 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { createMockProviderContextValue } from '@/__mocks__/provider-context' -// ============================================================================ -// Import Components After Mocks Setup -// ============================================================================ +import Conversion from '../conversion' +import RagPipelinePanel from '../panel' +import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal' +import PublishToast from '../publish-toast' +import RagPipelineChildren from '../rag-pipeline-children' +import PipelineScreenShot from '../screenshot' -import Conversion from './conversion' -import RagPipelinePanel from './panel' -import PublishAsKnowledgePipelineModal from './publish-as-knowledge-pipeline-modal' -import PublishToast from './publish-toast' -import RagPipelineChildren from './rag-pipeline-children' -import PipelineScreenShot from './screenshot' - -// ============================================================================ -// Mock External Dependencies - All vi.mock calls must come before any imports -// ============================================================================ - -// Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ push: mockPush }), })) -// Mock next/image vi.mock('next/image', () => ({ default: ({ src, alt, width, height }: { src: string, alt: string, width: number, height: number }) => ( // eslint-disable-next-line next/no-img-element @@ -33,7 +23,6 @@ vi.mock('next/image', () => ({ ), })) -// Mock next/dynamic vi.mock('next/dynamic', () => ({ default: (importFn: () => Promise<{ default: React.ComponentType<unknown> }>, options?: { ssr?: boolean }) => { const DynamicComponent = ({ children, ...props }: PropsWithChildren) => { @@ -44,7 +33,6 @@ vi.mock('next/dynamic', () => ({ }, })) -// Mock workflow store - using controllable state let mockShowImportDSLModal = false const mockSetShowImportDSLModal = vi.fn((value: boolean) => { mockShowImportDSLModal = value @@ -112,7 +100,6 @@ vi.mock('@/app/components/workflow/store', () => { } }) -// Mock workflow hooks - extract mock functions for assertions using vi.hoisted const { mockHandlePaneContextmenuCancel, mockExportCheck, @@ -148,8 +135,7 @@ vi.mock('@/app/components/workflow/hooks', () => { } }) -// Mock rag-pipeline hooks -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useAvailableNodesMetaData: () => ({}), useDSL: () => ({ exportCheck: mockExportCheck, @@ -178,18 +164,15 @@ vi.mock('../hooks', () => ({ }), })) -// Mock rag-pipeline search hook -vi.mock('../hooks/use-rag-pipeline-search', () => ({ +vi.mock('../../hooks/use-rag-pipeline-search', () => ({ useRagPipelineSearch: vi.fn(), })) -// Mock configs-map hook -vi.mock('../hooks/use-configs-map', () => ({ +vi.mock('../../hooks/use-configs-map', () => ({ useConfigsMap: () => ({}), })) -// Mock inspect-vars-crud hook -vi.mock('../hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../hooks/use-inspect-vars-crud', () => ({ useInspectVarsCrud: () => ({ hasNodeInspectVars: vi.fn(), hasSetInspectVar: vi.fn(), @@ -208,14 +191,12 @@ vi.mock('../hooks/use-inspect-vars-crud', () => ({ }), })) -// Mock workflow hooks for fetch-workflow-inspect-vars vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: vi.fn(), }), })) -// Mock service hooks - with controllable convert function let mockConvertFn = vi.fn() let mockIsPending = false vi.mock('@/service/use-pipeline', () => ({ @@ -253,7 +234,6 @@ vi.mock('@/service/workflow', () => ({ }), })) -// Mock event emitter context - with controllable subscription let mockEventSubscriptionCallback: ((v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null const mockUseSubscription = vi.fn((callback: (v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => { mockEventSubscriptionCallback = callback @@ -267,7 +247,6 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -// Mock toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), @@ -280,33 +259,28 @@ vi.mock('@/app/components/base/toast', () => ({ }, })) -// Mock useTheme hook vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light', }), })) -// Mock basePath vi.mock('@/utils/var', () => ({ basePath: '/public', })) -// Mock provider context vi.mock('@/context/provider-context', () => ({ useProviderContext: () => createMockProviderContextValue(), useProviderContextSelector: <T,>(selector: (state: ReturnType<typeof createMockProviderContextValue>) => T): T => selector(createMockProviderContextValue()), })) -// Mock WorkflowWithInnerContext vi.mock('@/app/components/workflow', () => ({ WorkflowWithInnerContext: ({ children }: PropsWithChildren) => ( <div data-testid="workflow-inner-context">{children}</div> ), })) -// Mock workflow panel vi.mock('@/app/components/workflow/panel', () => ({ default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => ( <div data-testid="workflow-panel"> @@ -316,19 +290,16 @@ vi.mock('@/app/components/workflow/panel', () => ({ ), })) -// Mock PluginDependency -vi.mock('../../workflow/plugin-dependency', () => ({ +vi.mock('../../../workflow/plugin-dependency', () => ({ default: () => <div data-testid="plugin-dependency" />, })) -// Mock plugin-dependency hooks vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ usePluginDependencies: () => ({ handleCheckPluginDependencies: vi.fn().mockResolvedValue(undefined), }), })) -// Mock DSLExportConfirmModal vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ default: ({ envList, onConfirm, onClose }: { envList: EnvironmentVariable[], onConfirm: () => void, onClose: () => void }) => ( <div data-testid="dsl-export-confirm-modal"> @@ -339,13 +310,11 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ ), })) -// Mock workflow constants vi.mock('@/app/components/workflow/constants', () => ({ DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', })) -// Mock workflow utils vi.mock('@/app/components/workflow/utils', () => ({ initialNodes: vi.fn(nodes => nodes), initialEdges: vi.fn(edges => edges), @@ -353,7 +322,6 @@ vi.mock('@/app/components/workflow/utils', () => ({ getKeyboardKeyNameBySystem: (key: string) => key, })) -// Mock Confirm component vi.mock('@/app/components/base/confirm', () => ({ default: ({ title, content, isShow, onConfirm, onCancel, isLoading, isDisabled }: { title: string @@ -381,7 +349,6 @@ vi.mock('@/app/components/base/confirm', () => ({ : null, })) -// Mock Modal component vi.mock('@/app/components/base/modal', () => ({ default: ({ children, isShow, onClose, className }: PropsWithChildren<{ isShow: boolean @@ -396,7 +363,6 @@ vi.mock('@/app/components/base/modal', () => ({ : null, })) -// Mock Input component vi.mock('@/app/components/base/input', () => ({ default: ({ value, onChange, placeholder }: { value: string @@ -412,7 +378,6 @@ vi.mock('@/app/components/base/input', () => ({ ), })) -// Mock Textarea component vi.mock('@/app/components/base/textarea', () => ({ default: ({ value, onChange, placeholder, className }: { value: string @@ -430,7 +395,6 @@ vi.mock('@/app/components/base/textarea', () => ({ ), })) -// Mock AppIcon component vi.mock('@/app/components/base/app-icon', () => ({ default: ({ onClick, iconType, icon, background, imageUrl, className, size }: { onClick?: () => void @@ -454,7 +418,6 @@ vi.mock('@/app/components/base/app-icon', () => ({ ), })) -// Mock AppIconPicker component vi.mock('@/app/components/base/app-icon-picker', () => ({ default: ({ onSelect, onClose }: { onSelect: (item: { type: string, icon?: string, background?: string, url?: string }) => void @@ -478,7 +441,6 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({ ), })) -// Mock Uploader component vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ default: ({ file, updateFile, className, accept, displayName }: { file?: File @@ -504,25 +466,21 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ ), })) -// Mock use-context-selector vi.mock('use-context-selector', () => ({ useContext: vi.fn(() => ({ notify: vi.fn(), })), })) -// Mock RagPipelineHeader -vi.mock('./rag-pipeline-header', () => ({ +vi.mock('../rag-pipeline-header', () => ({ default: () => <div data-testid="rag-pipeline-header" />, })) -// Mock PublishToast -vi.mock('./publish-toast', () => ({ +vi.mock('../publish-toast', () => ({ default: () => <div data-testid="publish-toast" />, })) -// Mock UpdateDSLModal for RagPipelineChildren tests -vi.mock('./update-dsl-modal', () => ({ +vi.mock('../update-dsl-modal', () => ({ default: ({ onCancel, onBackup, onImport }: { onCancel: () => void onBackup: () => void @@ -536,7 +494,6 @@ vi.mock('./update-dsl-modal', () => ({ ), })) -// Mock DSLExportConfirmModal for RagPipelineChildren tests vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ default: ({ envList, onConfirm, onClose }: { envList: EnvironmentVariable[] @@ -555,18 +512,11 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ ), })) -// ============================================================================ -// Test Suites -// ============================================================================ - describe('Conversion', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render conversion component without crashing', () => { render(<Conversion />) @@ -600,9 +550,6 @@ describe('Conversion', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should show confirm modal when convert button is clicked', () => { render(<Conversion />) @@ -617,20 +564,15 @@ describe('Conversion', () => { it('should hide confirm modal when cancel is clicked', () => { render(<Conversion />) - // Open modal const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) fireEvent.click(convertButton) expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() - // Cancel modal fireEvent.click(screen.getByTestId('cancel-btn')) expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- - // API Callback Tests - covers lines 21-39 - // -------------------------------------------------------------------------- describe('API Callbacks', () => { beforeEach(() => { mockConvertFn = vi.fn() @@ -638,14 +580,12 @@ describe('Conversion', () => { }) it('should call convert with datasetId and show success toast on success', async () => { - // Setup mock to capture and call onSuccess callback mockConvertFn.mockImplementation((_datasetId: string, options: { onSuccess: (res: { status: string }) => void }) => { options.onSuccess({ status: 'success' }) }) render(<Conversion />) - // Open modal and confirm const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) fireEvent.click(convertButton) fireEvent.click(screen.getByTestId('confirm-btn')) @@ -690,7 +630,6 @@ describe('Conversion', () => { await waitFor(() => { expect(mockConvertFn).toHaveBeenCalled() }) - // Modal should still be visible since conversion failed expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() }) @@ -711,32 +650,23 @@ describe('Conversion', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with React.memo', () => { - // Conversion is exported with React.memo expect((Conversion as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) }) it('should use useCallback for handleConvert', () => { const { rerender } = render(<Conversion />) - // Rerender should not cause issues with callback rerender(<Conversion />) expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle missing datasetId gracefully', () => { render(<Conversion />) - // Component should render without crashing expect(screen.getByText('datasetPipeline.conversion.title')).toBeInTheDocument() }) }) @@ -747,9 +677,6 @@ describe('PipelineScreenShot', () => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render(<PipelineScreenShot />) @@ -770,14 +697,10 @@ describe('PipelineScreenShot', () => { render(<PipelineScreenShot />) const img = screen.getByTestId('mock-image') - // Default theme is 'light' from mock expect(img).toHaveAttribute('src', '/public/screenshots/light/Pipeline.png') }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with React.memo', () => { expect((PipelineScreenShot as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) @@ -790,9 +713,6 @@ describe('PublishToast', () => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { // Note: PublishToast is mocked, so we just verify the mock renders @@ -802,12 +722,8 @@ describe('PublishToast', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be defined', () => { - // The real PublishToast is mocked, but we can verify the import expect(PublishToast).toBeDefined() }) }) @@ -826,9 +742,6 @@ describe('PublishAsKnowledgePipelineModal', () => { onConfirm: mockOnConfirm, } - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render modal with title', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) @@ -863,9 +776,6 @@ describe('PublishAsKnowledgePipelineModal', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should update name when input changes', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) @@ -906,11 +816,9 @@ describe('PublishAsKnowledgePipelineModal', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - // Update values fireEvent.change(screen.getByTestId('input'), { target: { value: ' Trimmed Name ' } }) fireEvent.change(screen.getByTestId('textarea'), { target: { value: ' Trimmed Description ' } }) - // Click publish fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i })) expect(mockOnConfirm).toHaveBeenCalledWith( @@ -931,52 +839,39 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should update icon when emoji is selected', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - // Open picker fireEvent.click(screen.getByTestId('app-icon')) - // Select emoji fireEvent.click(screen.getByTestId('select-emoji')) - // Picker should close expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() }) it('should update icon when image is selected', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - // Open picker fireEvent.click(screen.getByTestId('app-icon')) - // Select image fireEvent.click(screen.getByTestId('select-image')) - // Picker should close expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() }) it('should close picker and restore icon when picker is closed', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - // Open picker fireEvent.click(screen.getByTestId('app-icon')) expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument() - // Close picker fireEvent.click(screen.getByTestId('close-picker')) - // Picker should close expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- - // Props Validation Tests - // -------------------------------------------------------------------------- describe('Props Validation', () => { it('should disable publish button when name is empty', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - // Clear the name fireEvent.change(screen.getByTestId('input'), { target: { value: '' } }) const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i }) @@ -986,7 +881,6 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should disable publish button when name is only whitespace', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - // Set whitespace-only name fireEvent.change(screen.getByTestId('input'), { target: { value: ' ' } }) const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i }) @@ -1009,14 +903,10 @@ describe('PublishAsKnowledgePipelineModal', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should use useCallback for handleSelectIcon', () => { const { rerender } = render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - // Rerender should not cause issues rerender(<PublishAsKnowledgePipelineModal {...defaultProps} />) expect(screen.getByTestId('app-icon')).toBeInTheDocument() }) @@ -1028,9 +918,6 @@ describe('RagPipelinePanel', () => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render panel component without crashing', () => { render(<RagPipelinePanel />) @@ -1046,9 +933,6 @@ describe('RagPipelinePanel', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with memo', () => { expect((RagPipelinePanel as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) @@ -1063,9 +947,6 @@ describe('RagPipelineChildren', () => { mockEventSubscriptionCallback = null }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render(<RagPipelineChildren />) @@ -1090,9 +971,6 @@ describe('RagPipelineChildren', () => { }) }) - // -------------------------------------------------------------------------- - // Event Subscription Tests - covers lines 37-40 - // -------------------------------------------------------------------------- describe('Event Subscription', () => { it('should subscribe to event emitter', () => { render(<RagPipelineChildren />) @@ -1103,12 +981,10 @@ describe('RagPipelineChildren', () => { it('should handle DSL_EXPORT_CHECK event and set secretEnvList', async () => { render(<RagPipelineChildren />) - // Simulate DSL_EXPORT_CHECK event const mockEnvVariables: EnvironmentVariable[] = [ { id: '1', name: 'SECRET_KEY', value: 'test-secret', value_type: 'secret' as const, description: '' }, ] - // Trigger the subscription callback if (mockEventSubscriptionCallback) { mockEventSubscriptionCallback({ type: 'DSL_EXPORT_CHECK', @@ -1116,7 +992,6 @@ describe('RagPipelineChildren', () => { }) } - // DSLExportConfirmModal should be rendered await waitFor(() => { expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument() }) @@ -1125,7 +1000,6 @@ describe('RagPipelineChildren', () => { it('should not show DSLExportConfirmModal for non-DSL_EXPORT_CHECK events', () => { render(<RagPipelineChildren />) - // Trigger a different event type if (mockEventSubscriptionCallback) { mockEventSubscriptionCallback({ type: 'OTHER_EVENT', @@ -1136,9 +1010,6 @@ describe('RagPipelineChildren', () => { }) }) - // -------------------------------------------------------------------------- - // UpdateDSLModal Handlers Tests - covers lines 48-51 - // -------------------------------------------------------------------------- describe('UpdateDSLModal Handlers', () => { beforeEach(() => { mockShowImportDSLModal = true @@ -1168,14 +1039,10 @@ describe('RagPipelineChildren', () => { }) }) - // -------------------------------------------------------------------------- - // DSLExportConfirmModal Tests - covers lines 55-60 - // -------------------------------------------------------------------------- describe('DSLExportConfirmModal', () => { it('should render DSLExportConfirmModal when secretEnvList has items', async () => { render(<RagPipelineChildren />) - // Simulate DSL_EXPORT_CHECK event with secrets const mockEnvVariables: EnvironmentVariable[] = [ { id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' }, ] @@ -1195,7 +1062,6 @@ describe('RagPipelineChildren', () => { it('should close DSLExportConfirmModal when onClose is triggered', async () => { render(<RagPipelineChildren />) - // First show the modal const mockEnvVariables: EnvironmentVariable[] = [ { id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' }, ] @@ -1211,7 +1077,6 @@ describe('RagPipelineChildren', () => { expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument() }) - // Close the modal fireEvent.click(screen.getByTestId('dsl-export-close')) await waitFor(() => { @@ -1222,7 +1087,6 @@ describe('RagPipelineChildren', () => { it('should call handleExportDSL when onConfirm is triggered', async () => { render(<RagPipelineChildren />) - // Show the modal const mockEnvVariables: EnvironmentVariable[] = [ { id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' }, ] @@ -1238,16 +1102,12 @@ describe('RagPipelineChildren', () => { expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument() }) - // Confirm export fireEvent.click(screen.getByTestId('dsl-export-confirm')) expect(mockHandleExportDSL).toHaveBeenCalledTimes(1) }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with memo', () => { expect((RagPipelineChildren as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) @@ -1255,10 +1115,6 @@ describe('RagPipelineChildren', () => { }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('Integration Tests', () => { beforeEach(() => { vi.clearAllMocks() @@ -1276,17 +1132,13 @@ describe('Integration Tests', () => { />, ) - // Update name fireEvent.change(screen.getByTestId('input'), { target: { value: 'My Pipeline' } }) - // Add description fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'A great pipeline' } }) - // Change icon fireEvent.click(screen.getByTestId('app-icon')) fireEvent.click(screen.getByTestId('select-emoji')) - // Publish fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i })) await waitFor(() => { @@ -1304,10 +1156,6 @@ describe('Integration Tests', () => { }) }) -// ============================================================================ -// Edge Cases -// ============================================================================ - describe('Edge Cases', () => { beforeEach(() => { vi.clearAllMocks() @@ -1322,7 +1170,6 @@ describe('Edge Cases', () => { />, ) - // Clear the name const input = screen.getByTestId('input') fireEvent.change(input, { target: { value: '' } }) expect(input).toHaveValue('') @@ -1360,10 +1207,6 @@ describe('Edge Cases', () => { }) }) -// ============================================================================ -// Accessibility Tests -// ============================================================================ - describe('Accessibility', () => { describe('Conversion', () => { it('should have accessible button', () => { diff --git a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx new file mode 100644 index 0000000000..0d6687cbed --- /dev/null +++ b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx @@ -0,0 +1,244 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal' + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + knowledgeName: 'Test Pipeline', + knowledgeIcon: { + icon_type: 'emoji', + icon: '🔧', + icon_background: '#fff', + icon_url: '', + }, + }), + }), +})) + +vi.mock('@/app/components/base/modal', () => ({ + default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => + isShow ? <div data-testid="modal">{children}</div> : null, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, disabled, ...props }: Record<string, unknown>) => ( + <button onClick={onClick as () => void} disabled={disabled as boolean} {...props}> + {children as string} + </button> + ), +})) + +vi.mock('@/app/components/base/input', () => ({ + default: ({ value, onChange, ...props }: Record<string, unknown>) => ( + <input + data-testid="name-input" + value={value as string} + onChange={onChange as () => void} + {...props} + /> + ), +})) + +vi.mock('@/app/components/base/textarea', () => ({ + default: ({ value, onChange, ...props }: Record<string, unknown>) => ( + <textarea + data-testid="description-textarea" + value={value as string} + onChange={onChange as () => void} + {...props} + /> + ), +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ onClick }: { onClick?: () => void }) => ( + <div data-testid="app-icon" onClick={onClick} /> + ), +})) + +vi.mock('@/app/components/base/app-icon-picker', () => ({ + default: ({ onSelect, onClose }: { onSelect: (item: { type: string, icon: string, background: string, url: string }) => void, onClose: () => void }) => ( + <div data-testid="icon-picker"> + <button data-testid="select-emoji" onClick={() => onSelect({ type: 'emoji', icon: '🎉', background: '#eee', url: '' })}> + Select Emoji + </button> + <button data-testid="select-image" onClick={() => onSelect({ type: 'image', icon: '', background: '', url: 'http://img.png' })}> + Select Image + </button> + <button data-testid="close-picker" onClick={onClose}> + Close + </button> + </div> + ), +})) + +vi.mock('es-toolkit/function', () => ({ + noop: () => {}, +})) + +describe('PublishAsKnowledgePipelineModal', () => { + const mockOnCancel = vi.fn() + const mockOnConfirm = vi.fn().mockResolvedValue(undefined) + + const defaultProps = { + onCancel: mockOnCancel, + onConfirm: mockOnConfirm, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should render modal with title', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument() + }) + + it('should initialize with knowledgeName from store', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + const nameInput = screen.getByTestId('name-input') as HTMLInputElement + expect(nameInput.value).toBe('Test Pipeline') + }) + + it('should initialize description as empty', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + const textarea = screen.getByTestId('description-textarea') as HTMLTextAreaElement + expect(textarea.value).toBe('') + }) + + it('should call onCancel when close button clicked', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + fireEvent.click(screen.getByTestId('publish-modal-close-btn')) + + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should call onCancel when cancel button clicked', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + fireEvent.click(screen.getByText('common.operation.cancel')) + + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should call onConfirm with name, icon, and description when confirm clicked', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + fireEvent.click(screen.getByText('workflow.common.publish')) + + expect(mockOnConfirm).toHaveBeenCalledWith( + 'Test Pipeline', + expect.objectContaining({ icon_type: 'emoji', icon: '🔧' }), + '', + ) + }) + + it('should update pipeline name when input changes', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + const nameInput = screen.getByTestId('name-input') + fireEvent.change(nameInput, { target: { value: 'New Name' } }) + + expect((nameInput as HTMLInputElement).value).toBe('New Name') + }) + + it('should update description when textarea changes', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + const textarea = screen.getByTestId('description-textarea') + fireEvent.change(textarea, { target: { value: 'My description' } }) + + expect((textarea as HTMLTextAreaElement).value).toBe('My description') + }) + + it('should disable confirm button when name is empty', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + const nameInput = screen.getByTestId('name-input') + fireEvent.change(nameInput, { target: { value: '' } }) + + const confirmBtn = screen.getByText('workflow.common.publish') + expect(confirmBtn).toBeDisabled() + }) + + it('should disable confirm button when confirmDisabled is true', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} confirmDisabled />) + + const confirmBtn = screen.getByText('workflow.common.publish') + expect(confirmBtn).toBeDisabled() + }) + + it('should not call onConfirm when confirmDisabled is true', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} confirmDisabled />) + + fireEvent.click(screen.getByText('workflow.common.publish')) + + expect(mockOnConfirm).not.toHaveBeenCalled() + }) + + it('should show icon picker when app icon clicked', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('app-icon')) + + expect(screen.getByTestId('icon-picker')).toBeInTheDocument() + }) + + it('should update icon when emoji is selected', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + fireEvent.click(screen.getByTestId('app-icon')) + fireEvent.click(screen.getByTestId('select-emoji')) + + expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument() + }) + + it('should update icon when image is selected', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + fireEvent.click(screen.getByTestId('app-icon')) + fireEvent.click(screen.getByTestId('select-image')) + + expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument() + }) + + it('should close icon picker when close is clicked', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + fireEvent.click(screen.getByTestId('app-icon')) + fireEvent.click(screen.getByTestId('close-picker')) + + expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument() + }) + + it('should trim name and description before submitting', () => { + render(<PublishAsKnowledgePipelineModal {...defaultProps} />) + + const nameInput = screen.getByTestId('name-input') + fireEvent.change(nameInput, { target: { value: ' Trimmed Name ' } }) + + const textarea = screen.getByTestId('description-textarea') + fireEvent.change(textarea, { target: { value: ' Some desc ' } }) + + fireEvent.click(screen.getByText('workflow.common.publish')) + + expect(mockOnConfirm).toHaveBeenCalledWith( + 'Trimmed Name', + expect.any(Object), + 'Some desc', + ) + }) +}) diff --git a/web/app/components/rag-pipeline/components/publish-toast.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/publish-toast.spec.tsx similarity index 69% rename from web/app/components/rag-pipeline/components/publish-toast.spec.tsx rename to web/app/components/rag-pipeline/components/__tests__/publish-toast.spec.tsx index d61f091ed2..0e65f8f1db 100644 --- a/web/app/components/rag-pipeline/components/publish-toast.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/publish-toast.spec.tsx @@ -1,15 +1,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import PublishToast from './publish-toast' +import PublishToast from '../publish-toast' -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock workflow store with controllable state let mockPublishedAt = 0 vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: Record<string, unknown>) => unknown) => { @@ -32,19 +24,19 @@ describe('PublishToast', () => { mockPublishedAt = 0 render(<PublishToast />) - expect(screen.getByText('publishToast.title')).toBeInTheDocument() + expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument() }) it('should render toast title', () => { render(<PublishToast />) - expect(screen.getByText('publishToast.title')).toBeInTheDocument() + expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument() }) it('should render toast description', () => { render(<PublishToast />) - expect(screen.getByText('publishToast.desc')).toBeInTheDocument() + expect(screen.getByText('pipeline.publishToast.desc')).toBeInTheDocument() }) it('should not render when publishedAt is set', () => { @@ -57,14 +49,13 @@ describe('PublishToast', () => { it('should have correct positioning classes', () => { render(<PublishToast />) - const container = screen.getByText('publishToast.title').closest('.absolute') + const container = screen.getByText('pipeline.publishToast.title').closest('.absolute') expect(container).toHaveClass('bottom-[45px]', 'left-0', 'right-0', 'z-10') }) it('should render info icon', () => { const { container } = render(<PublishToast />) - // The RiInformation2Fill icon should be rendered const iconContainer = container.querySelector('.text-text-accent') expect(iconContainer).toBeInTheDocument() }) @@ -72,7 +63,6 @@ describe('PublishToast', () => { it('should render close button', () => { const { container } = render(<PublishToast />) - // The close button is a div with cursor-pointer, not a semantic button const closeButton = container.querySelector('.cursor-pointer') expect(closeButton).toBeInTheDocument() }) @@ -82,25 +72,23 @@ describe('PublishToast', () => { it('should hide toast when close button is clicked', () => { const { container } = render(<PublishToast />) - // The close button is a div with cursor-pointer, not a semantic button const closeButton = container.querySelector('.cursor-pointer') - expect(screen.getByText('publishToast.title')).toBeInTheDocument() + expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument() fireEvent.click(closeButton!) - expect(screen.queryByText('publishToast.title')).not.toBeInTheDocument() + expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument() }) it('should remain hidden after close button is clicked', () => { const { container, rerender } = render(<PublishToast />) - // The close button is a div with cursor-pointer, not a semantic button const closeButton = container.querySelector('.cursor-pointer') fireEvent.click(closeButton!) rerender(<PublishToast />) - expect(screen.queryByText('publishToast.title')).not.toBeInTheDocument() + expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument() }) }) @@ -115,14 +103,14 @@ describe('PublishToast', () => { it('should have correct toast width', () => { render(<PublishToast />) - const toastContainer = screen.getByText('publishToast.title').closest('.w-\\[420px\\]') + const toastContainer = screen.getByText('pipeline.publishToast.title').closest('.w-\\[420px\\]') expect(toastContainer).toBeInTheDocument() }) it('should have rounded border', () => { render(<PublishToast />) - const toastContainer = screen.getByText('publishToast.title').closest('.rounded-xl') + const toastContainer = screen.getByText('pipeline.publishToast.title').closest('.rounded-xl') expect(toastContainer).toBeInTheDocument() }) }) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-main.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx similarity index 94% rename from web/app/components/rag-pipeline/components/rag-pipeline-main.spec.tsx rename to web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx index 3de3c3deeb..22d38861da 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-main.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx @@ -2,10 +2,9 @@ import type { PropsWithChildren } from 'react' import type { Edge, Node, Viewport } from 'reactflow' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import RagPipelineMain from './rag-pipeline-main' +import RagPipelineMain from '../rag-pipeline-main' -// Mock hooks from ../hooks -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useAvailableNodesMetaData: () => ({ nodes: [], nodesMap: {} }), useDSL: () => ({ exportCheck: vi.fn(), @@ -34,8 +33,7 @@ vi.mock('../hooks', () => ({ }), })) -// Mock useConfigsMap -vi.mock('../hooks/use-configs-map', () => ({ +vi.mock('../../hooks/use-configs-map', () => ({ useConfigsMap: () => ({ flowId: 'test-flow-id', flowType: 'ragPipeline', @@ -43,8 +41,7 @@ vi.mock('../hooks/use-configs-map', () => ({ }), })) -// Mock useInspectVarsCrud -vi.mock('../hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../hooks/use-inspect-vars-crud', () => ({ useInspectVarsCrud: () => ({ hasNodeInspectVars: vi.fn(), hasSetInspectVar: vi.fn(), @@ -63,7 +60,6 @@ vi.mock('../hooks/use-inspect-vars-crud', () => ({ }), })) -// Mock workflow store const mockSetRagPipelineVariables = vi.fn() const mockSetEnvironmentVariables = vi.fn() vi.mock('@/app/components/workflow/store', () => ({ @@ -75,14 +71,12 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock workflow hooks vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: vi.fn(), }), })) -// Mock WorkflowWithInnerContext vi.mock('@/app/components/workflow', () => ({ WorkflowWithInnerContext: ({ children, onWorkflowDataUpdate }: PropsWithChildren<{ onWorkflowDataUpdate?: (payload: unknown) => void }>) => ( <div data-testid="workflow-inner-context"> @@ -108,8 +102,7 @@ vi.mock('@/app/components/workflow', () => ({ ), })) -// Mock RagPipelineChildren -vi.mock('./rag-pipeline-children', () => ({ +vi.mock('../rag-pipeline-children', () => ({ default: () => <div data-testid="rag-pipeline-children">Children</div>, })) @@ -201,7 +194,6 @@ describe('RagPipelineMain', () => { it('should use useNodesSyncDraft hook', () => { render(<RagPipelineMain {...defaultProps} />) - // If the component renders, the hook was called successfully expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument() }) diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx similarity index 77% rename from web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx rename to web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx index addfa3dc53..2f9b2172bd 100644 --- a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from 'react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DSLImportStatus } from '@/models/app' -import UpdateDSLModal from './update-dsl-modal' +import UpdateDSLModal from '../update-dsl-modal' class MockFileReader { onload: ((this: FileReader, event: ProgressEvent<FileReader>) => void) | null = null @@ -15,25 +15,15 @@ class MockFileReader { vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock use-context-selector const mockNotify = vi.fn() vi.mock('use-context-selector', () => ({ useContext: () => ({ notify: mockNotify }), })) -// Mock toast context vi.mock('@/app/components/base/toast', () => ({ ToastContext: { Provider: ({ children }: PropsWithChildren) => children }, })) -// Mock event emitter const mockEmit = vi.fn() vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ @@ -41,7 +31,6 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -// Mock workflow store vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => ({ getState: () => ({ @@ -50,13 +39,11 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock workflow utils vi.mock('@/app/components/workflow/utils', () => ({ initialNodes: (nodes: unknown[]) => nodes, initialEdges: (edges: unknown[]) => edges, })) -// Mock plugin dependencies const mockHandleCheckPluginDependencies = vi.fn() vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ usePluginDependencies: () => ({ @@ -64,7 +51,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ }), })) -// Mock pipeline service const mockImportDSL = vi.fn() const mockImportDSLConfirm = vi.fn() vi.mock('@/service/use-pipeline', () => ({ @@ -72,7 +58,6 @@ vi.mock('@/service/use-pipeline', () => ({ useImportPipelineDSLConfirm: () => ({ mutateAsync: mockImportDSLConfirm }), })) -// Mock workflow service vi.mock('@/service/workflow', () => ({ fetchWorkflowDraft: vi.fn().mockResolvedValue({ graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, @@ -81,7 +66,6 @@ vi.mock('@/service/workflow', () => ({ }), })) -// Mock Uploader vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ default: ({ updateFile }: { updateFile: (file?: File) => void }) => ( <div data-testid="uploader"> @@ -103,7 +87,6 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ ), })) -// Mock Button vi.mock('@/app/components/base/button', () => ({ default: ({ children, onClick, disabled, className, variant, loading }: { children: React.ReactNode @@ -125,7 +108,6 @@ vi.mock('@/app/components/base/button', () => ({ ), })) -// Mock Modal vi.mock('@/app/components/base/modal', () => ({ default: ({ children, isShow, _onClose, className }: PropsWithChildren<{ isShow: boolean @@ -140,7 +122,6 @@ vi.mock('@/app/components/base/modal', () => ({ : null, })) -// Mock workflow constants vi.mock('@/app/components/workflow/constants', () => ({ WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', })) @@ -176,15 +157,13 @@ describe('UpdateDSLModal', () => { it('should render title', () => { render(<UpdateDSLModal {...defaultProps} />) - // The component uses t('common.importDSL', { ns: 'workflow' }) which returns 'common.importDSL' - expect(screen.getByText('common.importDSL')).toBeInTheDocument() + expect(screen.getByText('workflow.common.importDSL')).toBeInTheDocument() }) it('should render warning tip', () => { render(<UpdateDSLModal {...defaultProps} />) - // The component uses t('common.importDSLTip', { ns: 'workflow' }) - expect(screen.getByText('common.importDSLTip')).toBeInTheDocument() + expect(screen.getByText('workflow.common.importDSLTip')).toBeInTheDocument() }) it('should render uploader', () => { @@ -196,29 +175,25 @@ describe('UpdateDSLModal', () => { it('should render backup button', () => { render(<UpdateDSLModal {...defaultProps} />) - // The component uses t('common.backupCurrentDraft', { ns: 'workflow' }) - expect(screen.getByText('common.backupCurrentDraft')).toBeInTheDocument() + expect(screen.getByText('workflow.common.backupCurrentDraft')).toBeInTheDocument() }) it('should render cancel button', () => { render(<UpdateDSLModal {...defaultProps} />) - // The component uses t('newApp.Cancel', { ns: 'app' }) - expect(screen.getByText('newApp.Cancel')).toBeInTheDocument() + expect(screen.getByText('app.newApp.Cancel')).toBeInTheDocument() }) it('should render import button', () => { render(<UpdateDSLModal {...defaultProps} />) - // The component uses t('common.overwriteAndImport', { ns: 'workflow' }) - expect(screen.getByText('common.overwriteAndImport')).toBeInTheDocument() + expect(screen.getByText('workflow.common.overwriteAndImport')).toBeInTheDocument() }) it('should render choose DSL section', () => { render(<UpdateDSLModal {...defaultProps} />) - // The component uses t('common.chooseDSL', { ns: 'workflow' }) - expect(screen.getByText('common.chooseDSL')).toBeInTheDocument() + expect(screen.getByText('workflow.common.chooseDSL')).toBeInTheDocument() }) }) @@ -226,7 +201,7 @@ describe('UpdateDSLModal', () => { it('should call onCancel when cancel button is clicked', () => { render(<UpdateDSLModal {...defaultProps} />) - const cancelButton = screen.getByText('newApp.Cancel') + const cancelButton = screen.getByText('app.newApp.Cancel') fireEvent.click(cancelButton) expect(mockOnCancel).toHaveBeenCalled() @@ -235,7 +210,7 @@ describe('UpdateDSLModal', () => { it('should call onBackup when backup button is clicked', () => { render(<UpdateDSLModal {...defaultProps} />) - const backupButton = screen.getByText('common.backupCurrentDraft') + const backupButton = screen.getByText('workflow.common.backupCurrentDraft') fireEvent.click(backupButton) expect(mockOnBackup).toHaveBeenCalled() @@ -249,7 +224,6 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) - // File should be processed await waitFor(() => { expect(screen.getByTestId('uploader')).toBeInTheDocument() }) @@ -261,14 +235,12 @@ describe('UpdateDSLModal', () => { const clearButton = screen.getByTestId('clear-file') fireEvent.click(clearButton) - // File should be cleared expect(screen.getByTestId('uploader')).toBeInTheDocument() }) it('should call onCancel when close icon is clicked', () => { render(<UpdateDSLModal {...defaultProps} />) - // The close icon is in a div with onClick={onCancel} const closeIconContainer = document.querySelector('.cursor-pointer') if (closeIconContainer) { fireEvent.click(closeIconContainer) @@ -281,7 +253,7 @@ describe('UpdateDSLModal', () => { it('should show import button disabled when no file is selected', () => { render(<UpdateDSLModal {...defaultProps} />) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).toBeDisabled() }) @@ -294,7 +266,7 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) }) @@ -302,22 +274,20 @@ describe('UpdateDSLModal', () => { it('should disable import button after file is cleared', async () => { render(<UpdateDSLModal {...defaultProps} />) - // First select a file const fileInput = screen.getByTestId('file-input') const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - // Clear the file const clearButton = screen.getByTestId('clear-file') fireEvent.click(clearButton) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).toBeDisabled() }) }) @@ -344,15 +314,14 @@ describe('UpdateDSLModal', () => { it('should render import button with warning variant', () => { render(<UpdateDSLModal {...defaultProps} />) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).toHaveAttribute('data-variant', 'warning') }) it('should render backup button with secondary variant', () => { render(<UpdateDSLModal {...defaultProps} />) - // The backup button text is inside a nested div, so we need to find the closest button - const backupButtonText = screen.getByText('common.backupCurrentDraft') + const backupButtonText = screen.getByText('workflow.common.backupCurrentDraft') const backupButton = backupButtonText.closest('button') expect(backupButton).toHaveAttribute('data-variant', 'secondary') }) @@ -362,22 +331,18 @@ describe('UpdateDSLModal', () => { it('should call importDSL when import button is clicked with file content', async () => { render(<UpdateDSLModal {...defaultProps} />) - // Select a file const fileInput = screen.getByTestId('file-input') const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) fireEvent.change(fileInput, { target: { files: [file] } }) - // Wait for FileReader to process await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - // Click import button - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - // Wait for import to be called await waitFor(() => { expect(mockImportDSL).toHaveBeenCalled() }) @@ -392,17 +357,16 @@ describe('UpdateDSLModal', () => { render(<UpdateDSLModal {...defaultProps} />) - // Select a file and click import const fileInput = screen.getByTestId('file-input') const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { @@ -426,11 +390,11 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { @@ -452,11 +416,11 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }, { timeout: 1000 }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { @@ -478,11 +442,11 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { @@ -506,11 +470,11 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { @@ -533,13 +497,12 @@ describe('UpdateDSLModal', () => { const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) fireEvent.change(fileInput, { target: { files: [file] } }) - // Wait for FileReader to process and button to be enabled await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { @@ -558,13 +521,12 @@ describe('UpdateDSLModal', () => { const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) fireEvent.change(fileInput, { target: { files: [file] } }) - // Wait for FileReader to complete and button to be enabled await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { @@ -588,16 +550,15 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - // Flush the FileReader microtask to ensure fileContent is set await act(async () => { await new Promise<void>(resolve => queueMicrotask(resolve)) }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { @@ -619,11 +580,11 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { @@ -649,23 +610,20 @@ describe('UpdateDSLModal', () => { await act(async () => { fireEvent.change(fileInput, { target: { files: [file] } }) - // Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask) await new Promise<void>(resolve => queueMicrotask(resolve)) }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() await act(async () => { fireEvent.click(importButton) - // Flush the promise resolution from mockImportDSL await Promise.resolve() - // Advance past the 300ms setTimeout in the component await vi.advanceTimersByTimeAsync(350) }) await waitFor(() => { - expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() }) vi.useRealTimers() @@ -687,14 +645,13 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - // Wait for error modal with version info await waitFor(() => { expect(screen.getByText('1.0.0')).toBeInTheDocument() expect(screen.getByText('2.0.0')).toBeInTheDocument() @@ -717,20 +674,18 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - // Wait for error modal await waitFor(() => { - expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() }, { timeout: 1000 }) - // Find and click cancel button in error modal - it should be the one with secondary variant - const cancelButtons = screen.getAllByText('newApp.Cancel') + const cancelButtons = screen.getAllByText('app.newApp.Cancel') const errorModalCancelButton = cancelButtons.find(btn => btn.getAttribute('data-variant') === 'secondary', ) @@ -738,9 +693,8 @@ describe('UpdateDSLModal', () => { fireEvent.click(errorModalCancelButton) } - // Modal should be closed await waitFor(() => { - expect(screen.queryByText('newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument() + expect(screen.queryByText('app.newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument() }) }) @@ -767,27 +721,23 @@ describe('UpdateDSLModal', () => { await act(async () => { fireEvent.change(fileInput, { target: { files: [file] } }) - // Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask) await new Promise<void>(resolve => queueMicrotask(resolve)) }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() await act(async () => { fireEvent.click(importButton) - // Flush the promise resolution from mockImportDSL await Promise.resolve() - // Advance past the 300ms setTimeout in the component await vi.advanceTimersByTimeAsync(350) }) await waitFor(() => { - expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() }, { timeout: 1000 }) - // Click confirm button - const confirmButton = screen.getByText('newApp.Confirm') + const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { @@ -818,18 +768,18 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { - expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() }, { timeout: 1000 }) - const confirmButton = screen.getByText('newApp.Confirm') + const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { @@ -860,18 +810,18 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { - expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() }, { timeout: 1000 }) - const confirmButton = screen.getByText('newApp.Confirm') + const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { @@ -899,18 +849,18 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { - expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() }, { timeout: 1000 }) - const confirmButton = screen.getByText('newApp.Confirm') + const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { @@ -941,18 +891,18 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { - expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() }, { timeout: 1000 }) - const confirmButton = screen.getByText('newApp.Confirm') + const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { @@ -983,18 +933,18 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) await waitFor(() => { - expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() }, { timeout: 1000 }) - const confirmButton = screen.getByText('newApp.Confirm') + const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { @@ -1025,26 +975,23 @@ describe('UpdateDSLModal', () => { await act(async () => { fireEvent.change(fileInput, { target: { files: [file] } }) - // Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask) await new Promise<void>(resolve => queueMicrotask(resolve)) }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() await act(async () => { fireEvent.click(importButton) - // Flush the promise resolution from mockImportDSL await Promise.resolve() - // Advance past the 300ms setTimeout in the component await vi.advanceTimersByTimeAsync(350) }) await waitFor(() => { - expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() }, { timeout: 1000 }) - const confirmButton = screen.getByText('newApp.Confirm') + const confirmButton = screen.getByText('app.newApp.Confirm') fireEvent.click(confirmButton) await waitFor(() => { @@ -1070,25 +1017,21 @@ describe('UpdateDSLModal', () => { fireEvent.change(fileInput, { target: { files: [file] } }) await waitFor(() => { - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - const importButton = screen.getByText('common.overwriteAndImport') + const importButton = screen.getByText('workflow.common.overwriteAndImport') fireEvent.click(importButton) - // Should show error modal even with undefined versions await waitFor(() => { - expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() }, { timeout: 1000 }) }) it('should not call importDSLConfirm when importId is not set', async () => { - // Render without triggering PENDING status first render(<UpdateDSLModal {...defaultProps} />) - // importId is not set, so confirm should not be called - // This is hard to test directly, but we can verify by checking the confirm flow expect(mockImportDSLConfirm).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/rag-pipeline/components/version-mismatch-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx similarity index 98% rename from web/app/components/rag-pipeline/components/version-mismatch-modal.spec.tsx rename to web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx index b14cdcf9c1..087f900f8a 100644 --- a/web/app/components/rag-pipeline/components/version-mismatch-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import VersionMismatchModal from './version-mismatch-modal' +import VersionMismatchModal from '../version-mismatch-modal' describe('VersionMismatchModal', () => { const mockOnClose = vi.fn() diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx new file mode 100644 index 0000000000..59cd9613f3 --- /dev/null +++ b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx @@ -0,0 +1,212 @@ +import type { ParentChildChunk } from '../types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { ChunkingMode } from '@/models/datasets' + +import ChunkCard from '../chunk-card' + +vi.mock('@/app/components/datasets/documents/detail/completed/common/dot', () => ({ + default: () => <span data-testid="dot" />, +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/segment-index-tag', () => ({ + default: ({ positionId, labelPrefix }: { positionId?: string | number, labelPrefix: string }) => ( + <span data-testid="segment-tag"> + {labelPrefix} + - + {positionId} + </span> + ), +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/summary-label', () => ({ + default: ({ summary }: { summary: string }) => <span data-testid="summary">{summary}</span>, +})) + +vi.mock('@/app/components/datasets/formatted-text/flavours/preview-slice', () => ({ + PreviewSlice: ({ label, text }: { label: string, text: string }) => ( + <span data-testid="preview-slice"> + {label} + : + {' '} + {text} + </span> + ), +})) + +vi.mock('@/models/datasets', () => ({ + ChunkingMode: { + text: 'text', + parentChild: 'parent-child', + qa: 'qa', + }, +})) + +vi.mock('@/utils/format', () => ({ + formatNumber: (n: number) => String(n), +})) + +vi.mock('../q-a-item', () => ({ + default: ({ type, text }: { type: string, text: string }) => ( + <span data-testid={`qa-${type}`}>{text}</span> + ), +})) + +vi.mock('../types', () => ({ + QAItemType: { + Question: 'question', + Answer: 'answer', + }, +})) + +const makeParentChildContent = (overrides: Partial<ParentChildChunk> = {}): ParentChildChunk => ({ + child_contents: ['Child'], + parent_content: '', + parent_summary: '', + parent_mode: 'paragraph', + ...overrides, +}) + +describe('ChunkCard', () => { + describe('Text mode', () => { + it('should render text content', () => { + render( + <ChunkCard + chunkType={ChunkingMode.text} + content={{ content: 'Hello world', summary: 'Summary text' }} + positionId={1} + wordCount={42} + />, + ) + + expect(screen.getByText('Hello world')).toBeInTheDocument() + }) + + it('should render segment index tag with Chunk prefix', () => { + render( + <ChunkCard + chunkType={ChunkingMode.text} + content={{ content: 'Test', summary: '' }} + positionId={5} + wordCount={10} + />, + ) + + expect(screen.getByText('Chunk-5')).toBeInTheDocument() + }) + + it('should render word count', () => { + render( + <ChunkCard + chunkType={ChunkingMode.text} + content={{ content: 'Test', summary: '' }} + positionId={1} + wordCount={100} + />, + ) + + expect(screen.getByText(/100/)).toBeInTheDocument() + }) + + it('should render summary when available', () => { + render( + <ChunkCard + chunkType={ChunkingMode.text} + content={{ content: 'Test', summary: 'A summary' }} + positionId={1} + wordCount={10} + />, + ) + + expect(screen.getByTestId('summary')).toHaveTextContent('A summary') + }) + }) + + describe('Parent-Child mode (paragraph)', () => { + it('should render child contents as preview slices', () => { + render( + <ChunkCard + chunkType={ChunkingMode.parentChild} + parentMode="paragraph" + content={makeParentChildContent({ + child_contents: ['Child 1', 'Child 2'], + parent_summary: 'Parent summary', + })} + positionId={3} + wordCount={50} + />, + ) + + const slices = screen.getAllByTestId('preview-slice') + expect(slices).toHaveLength(2) + expect(slices[0]).toHaveTextContent('C-1: Child 1') + expect(slices[1]).toHaveTextContent('C-2: Child 2') + }) + + it('should render Parent-Chunk prefix for paragraph mode', () => { + render( + <ChunkCard + chunkType={ChunkingMode.parentChild} + parentMode="paragraph" + content={makeParentChildContent()} + positionId={2} + wordCount={20} + />, + ) + + expect(screen.getByText('Parent-Chunk-2')).toBeInTheDocument() + }) + + it('should render parent summary', () => { + render( + <ChunkCard + chunkType={ChunkingMode.parentChild} + parentMode="paragraph" + content={makeParentChildContent({ + child_contents: ['C1'], + parent_summary: 'Overview', + })} + positionId={1} + wordCount={10} + />, + ) + + expect(screen.getByTestId('summary')).toHaveTextContent('Overview') + }) + }) + + describe('Parent-Child mode (full-doc)', () => { + it('should hide segment tag in full-doc mode', () => { + render( + <ChunkCard + chunkType={ChunkingMode.parentChild} + parentMode="full-doc" + content={makeParentChildContent({ + child_contents: ['Full doc child'], + parent_mode: 'full-doc', + })} + positionId={1} + wordCount={300} + />, + ) + + expect(screen.queryByTestId('segment-tag')).not.toBeInTheDocument() + }) + }) + + describe('QA mode', () => { + it('should render question and answer items', () => { + render( + <ChunkCard + chunkType={ChunkingMode.qa} + content={{ question: 'What is X?', answer: 'X is Y' }} + positionId={1} + wordCount={15} + />, + ) + + expect(screen.getByTestId('qa-question')).toHaveTextContent('What is X?') + expect(screen.getByTestId('qa-answer')).toHaveTextContent('X is Y') + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/index.spec.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/index.spec.tsx similarity index 83% rename from web/app/components/rag-pipeline/components/chunk-card-list/index.spec.tsx rename to web/app/components/rag-pipeline/components/chunk-card-list/__tests__/index.spec.tsx index ca5fae25c7..2fab56f0ea 100644 --- a/web/app/components/rag-pipeline/components/chunk-card-list/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/index.spec.tsx @@ -1,14 +1,10 @@ -import type { GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from './types' +import type { GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from '../types' import { render, screen } from '@testing-library/react' import { ChunkingMode } from '@/models/datasets' -import ChunkCard from './chunk-card' -import { ChunkCardList } from './index' -import QAItem from './q-a-item' -import { QAItemType } from './types' - -// ============================================================================= -// Test Data Factories -// ============================================================================= +import ChunkCard from '../chunk-card' +import { ChunkCardList } from '../index' +import QAItem from '../q-a-item' +import { QAItemType } from '../types' const createGeneralChunks = (overrides: GeneralChunks = []): GeneralChunks => { if (overrides.length > 0) @@ -56,99 +52,71 @@ const createQAChunks = (overrides: Partial<QAChunks> = {}): QAChunks => ({ ...overrides, }) -// ============================================================================= -// QAItem Component Tests -// ============================================================================= - describe('QAItem', () => { beforeEach(() => { vi.clearAllMocks() }) - // Tests for basic rendering of QAItem component describe('Rendering', () => { it('should render question type with Q prefix', () => { - // Arrange & Act render(<QAItem type={QAItemType.Question} text="What is this?" />) - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('What is this?')).toBeInTheDocument() }) it('should render answer type with A prefix', () => { - // Arrange & Act render(<QAItem type={QAItemType.Answer} text="This is the answer." />) - // Assert expect(screen.getByText('A')).toBeInTheDocument() expect(screen.getByText('This is the answer.')).toBeInTheDocument() }) }) - // Tests for different prop variations describe('Props', () => { it('should render with empty text', () => { - // Arrange & Act render(<QAItem type={QAItemType.Question} text="" />) - // Assert expect(screen.getByText('Q')).toBeInTheDocument() }) it('should render with long text content', () => { - // Arrange const longText = 'A'.repeat(1000) - // Act render(<QAItem type={QAItemType.Answer} text={longText} />) - // Assert expect(screen.getByText(longText)).toBeInTheDocument() }) it('should render with special characters in text', () => { - // Arrange const specialText = '<script>alert("xss")</script> & "quotes" \'apostrophe\'' - // Act render(<QAItem type={QAItemType.Question} text={specialText} />) - // Assert expect(screen.getByText(specialText)).toBeInTheDocument() }) }) - // Tests for memoization behavior describe('Memoization', () => { it('should be memoized with React.memo', () => { - // Arrange & Act const { rerender } = render(<QAItem type={QAItemType.Question} text="Test" />) - // Assert - component should render consistently expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('Test')).toBeInTheDocument() - // Rerender with same props - should not cause issues rerender(<QAItem type={QAItemType.Question} text="Test" />) expect(screen.getByText('Q')).toBeInTheDocument() }) }) }) -// ============================================================================= -// ChunkCard Component Tests -// ============================================================================= - describe('ChunkCard', () => { beforeEach(() => { vi.clearAllMocks() }) - // Tests for basic rendering with different chunk types describe('Rendering', () => { it('should render text chunk type correctly', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.text} @@ -158,19 +126,16 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText('This is the first chunk of text content.')).toBeInTheDocument() expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() }) it('should render QA chunk type with question and answer', () => { - // Arrange const qaContent: QAChunk = { question: 'What is React?', answer: 'React is a JavaScript library.', } - // Act render( <ChunkCard chunkType={ChunkingMode.qa} @@ -180,7 +145,6 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('What is React?')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() @@ -188,10 +152,8 @@ describe('ChunkCard', () => { }) it('should render parent-child chunk type with child contents', () => { - // Arrange const childContents = ['Child 1 content', 'Child 2 content'] - // Act render( <ChunkCard chunkType={ChunkingMode.parentChild} @@ -202,7 +164,6 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText('Child 1 content')).toBeInTheDocument() expect(screen.getByText('Child 2 content')).toBeInTheDocument() expect(screen.getByText('C-1')).toBeInTheDocument() @@ -210,10 +171,8 @@ describe('ChunkCard', () => { }) }) - // Tests for parent mode variations describe('Parent Mode Variations', () => { it('should show Parent-Chunk label prefix for paragraph mode', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.parentChild} @@ -224,12 +183,10 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText(/Parent-Chunk-01/)).toBeInTheDocument() }) it('should hide segment index tag for full-doc mode', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.parentChild} @@ -240,13 +197,11 @@ describe('ChunkCard', () => { />, ) - // Assert - should not show Chunk or Parent-Chunk label expect(screen.queryByText(/Chunk/)).not.toBeInTheDocument() expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument() }) it('should show Chunk label prefix for text mode', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.text} @@ -256,15 +211,12 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText(/Chunk-05/)).toBeInTheDocument() }) }) - // Tests for word count display describe('Word Count Display', () => { it('should display formatted word count', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.text} @@ -274,12 +226,10 @@ describe('ChunkCard', () => { />, ) - // Assert - formatNumber(1234) returns '1,234' expect(screen.getByText(/1,234/)).toBeInTheDocument() }) it('should display word count with character translation key', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.text} @@ -289,12 +239,10 @@ describe('ChunkCard', () => { />, ) - // Assert - translation key is returned as-is by mock expect(screen.getByText(/100\s+(?:\S.*)?characters/)).toBeInTheDocument() }) it('should not display word count info for full-doc mode', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.parentChild} @@ -305,15 +253,12 @@ describe('ChunkCard', () => { />, ) - // Assert - the header with word count should be hidden expect(screen.queryByText(/500/)).not.toBeInTheDocument() }) }) - // Tests for position ID variations describe('Position ID', () => { it('should handle numeric position ID', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.text} @@ -323,12 +268,10 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText(/Chunk-42/)).toBeInTheDocument() }) it('should handle string position ID', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.text} @@ -338,12 +281,10 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText(/Chunk-99/)).toBeInTheDocument() }) it('should pad single digit position ID', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.text} @@ -353,15 +294,12 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText(/Chunk-03/)).toBeInTheDocument() }) }) - // Tests for memoization dependencies describe('Memoization', () => { it('should update isFullDoc memo when parentMode changes', () => { - // Arrange const { rerender } = render( <ChunkCard chunkType={ChunkingMode.parentChild} @@ -372,10 +310,8 @@ describe('ChunkCard', () => { />, ) - // Assert - paragraph mode shows label expect(screen.getByText(/Parent-Chunk/)).toBeInTheDocument() - // Act - change to full-doc rerender( <ChunkCard chunkType={ChunkingMode.parentChild} @@ -386,12 +322,10 @@ describe('ChunkCard', () => { />, ) - // Assert - full-doc mode hides label expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument() }) it('should update contentElement memo when content changes', () => { - // Arrange const initialContent = { content: 'Initial content' } const updatedContent = { content: 'Updated content' } @@ -404,10 +338,8 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText('Initial content')).toBeInTheDocument() - // Act rerender( <ChunkCard chunkType={ChunkingMode.text} @@ -417,13 +349,11 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText('Updated content')).toBeInTheDocument() expect(screen.queryByText('Initial content')).not.toBeInTheDocument() }) it('should update contentElement memo when chunkType changes', () => { - // Arrange const textContent = { content: 'Text content' } const { rerender } = render( <ChunkCard @@ -434,10 +364,8 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText('Text content')).toBeInTheDocument() - // Act - change to QA type const qaContent: QAChunk = { question: 'Q?', answer: 'A.' } rerender( <ChunkCard @@ -448,16 +376,13 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('Q?')).toBeInTheDocument() }) }) - // Tests for edge cases describe('Edge Cases', () => { it('should handle empty child contents array', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.parentChild} @@ -468,15 +393,12 @@ describe('ChunkCard', () => { />, ) - // Assert - should render without errors expect(screen.getByText(/Parent-Chunk-01/)).toBeInTheDocument() }) it('should handle QA chunk with empty strings', () => { - // Arrange const emptyQA: QAChunk = { question: '', answer: '' } - // Act render( <ChunkCard chunkType={ChunkingMode.qa} @@ -486,17 +408,14 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) it('should handle very long content', () => { - // Arrange const longContent = 'A'.repeat(10000) const longContentChunk = { content: longContent } - // Act render( <ChunkCard chunkType={ChunkingMode.text} @@ -506,12 +425,10 @@ describe('ChunkCard', () => { />, ) - // Assert expect(screen.getByText(longContent)).toBeInTheDocument() }) it('should handle zero word count', () => { - // Arrange & Act render( <ChunkCard chunkType={ChunkingMode.text} @@ -521,28 +438,20 @@ describe('ChunkCard', () => { />, ) - // Assert - formatNumber returns falsy for 0, so it shows 0 expect(screen.getByText(/0\s+(?:\S.*)?characters/)).toBeInTheDocument() }) }) }) -// ============================================================================= -// ChunkCardList Component Tests -// ============================================================================= - describe('ChunkCardList', () => { beforeEach(() => { vi.clearAllMocks() }) - // Tests for rendering with different chunk types describe('Rendering', () => { it('should render text chunks correctly', () => { - // Arrange const chunks = createGeneralChunks() - // Act render( <ChunkCardList chunkType={ChunkingMode.text} @@ -550,17 +459,14 @@ describe('ChunkCardList', () => { />, ) - // Assert expect(screen.getByText(chunks[0].content)).toBeInTheDocument() expect(screen.getByText(chunks[1].content)).toBeInTheDocument() expect(screen.getByText(chunks[2].content)).toBeInTheDocument() }) it('should render parent-child chunks correctly', () => { - // Arrange const chunks = createParentChildChunks() - // Act render( <ChunkCardList chunkType={ChunkingMode.parentChild} @@ -569,17 +475,14 @@ describe('ChunkCardList', () => { />, ) - // Assert - should render child contents from parent-child chunks expect(screen.getByText('Child content 1')).toBeInTheDocument() expect(screen.getByText('Child content 2')).toBeInTheDocument() expect(screen.getByText('Another child 1')).toBeInTheDocument() }) it('should render QA chunks correctly', () => { - // Arrange const chunks = createQAChunks() - // Act render( <ChunkCardList chunkType={ChunkingMode.qa} @@ -587,7 +490,6 @@ describe('ChunkCardList', () => { />, ) - // Assert expect(screen.getByText('What is the answer to life?')).toBeInTheDocument() expect(screen.getByText('The answer is 42.')).toBeInTheDocument() expect(screen.getByText('How does this work?')).toBeInTheDocument() @@ -595,16 +497,13 @@ describe('ChunkCardList', () => { }) }) - // Tests for chunkList memoization describe('Memoization - chunkList', () => { it('should extract chunks from GeneralChunks for text mode', () => { - // Arrange const chunks: GeneralChunks = [ { content: 'Chunk 1' }, { content: 'Chunk 2' }, ] - // Act render( <ChunkCardList chunkType={ChunkingMode.text} @@ -612,20 +511,17 @@ describe('ChunkCardList', () => { />, ) - // Assert expect(screen.getByText('Chunk 1')).toBeInTheDocument() expect(screen.getByText('Chunk 2')).toBeInTheDocument() }) it('should extract parent_child_chunks from ParentChildChunks for parentChild mode', () => { - // Arrange const chunks = createParentChildChunks({ parent_child_chunks: [ createParentChildChunk({ child_contents: ['Specific child'] }), ], }) - // Act render( <ChunkCardList chunkType={ChunkingMode.parentChild} @@ -634,19 +530,16 @@ describe('ChunkCardList', () => { />, ) - // Assert expect(screen.getByText('Specific child')).toBeInTheDocument() }) it('should extract qa_chunks from QAChunks for qa mode', () => { - // Arrange const chunks: QAChunks = { qa_chunks: [ { question: 'Specific Q', answer: 'Specific A' }, ], } - // Act render( <ChunkCardList chunkType={ChunkingMode.qa} @@ -654,13 +547,11 @@ describe('ChunkCardList', () => { />, ) - // Assert expect(screen.getByText('Specific Q')).toBeInTheDocument() expect(screen.getByText('Specific A')).toBeInTheDocument() }) it('should update chunkList when chunkInfo changes', () => { - // Arrange const initialChunks = createGeneralChunks([{ content: 'Initial chunk' }]) const { rerender } = render( @@ -670,10 +561,8 @@ describe('ChunkCardList', () => { />, ) - // Assert initial state expect(screen.getByText('Initial chunk')).toBeInTheDocument() - // Act - update chunks const updatedChunks = createGeneralChunks([{ content: 'Updated chunk' }]) rerender( <ChunkCardList @@ -682,19 +571,15 @@ describe('ChunkCardList', () => { />, ) - // Assert updated state expect(screen.getByText('Updated chunk')).toBeInTheDocument() expect(screen.queryByText('Initial chunk')).not.toBeInTheDocument() }) }) - // Tests for getWordCount function describe('Word Count Calculation', () => { it('should calculate word count for text chunks using string length', () => { - // Arrange - "Hello" has 5 characters const chunks = createGeneralChunks([{ content: 'Hello' }]) - // Act render( <ChunkCardList chunkType={ChunkingMode.text} @@ -702,12 +587,10 @@ describe('ChunkCardList', () => { />, ) - // Assert - word count should be 5 (string length) expect(screen.getByText(/5\s+(?:\S.*)?characters/)).toBeInTheDocument() }) it('should calculate word count for parent-child chunks using parent_content length', () => { - // Arrange - parent_content length determines word count const chunks = createParentChildChunks({ parent_child_chunks: [ createParentChildChunk({ @@ -717,7 +600,6 @@ describe('ChunkCardList', () => { ], }) - // Act render( <ChunkCardList chunkType={ChunkingMode.parentChild} @@ -726,19 +608,16 @@ describe('ChunkCardList', () => { />, ) - // Assert - word count should be 6 (parent_content length) expect(screen.getByText(/6\s+(?:\S.*)?characters/)).toBeInTheDocument() }) it('should calculate word count for QA chunks using question + answer length', () => { - // Arrange - "Hi" (2) + "Bye" (3) = 5 const chunks: QAChunks = { qa_chunks: [ { question: 'Hi', answer: 'Bye' }, ], } - // Act render( <ChunkCardList chunkType={ChunkingMode.qa} @@ -746,22 +625,18 @@ describe('ChunkCardList', () => { />, ) - // Assert - word count should be 5 (question.length + answer.length) expect(screen.getByText(/5\s+(?:\S.*)?characters/)).toBeInTheDocument() }) }) - // Tests for position ID assignment describe('Position ID', () => { it('should assign 1-based position IDs to chunks', () => { - // Arrange const chunks = createGeneralChunks([ { content: 'First' }, { content: 'Second' }, { content: 'Third' }, ]) - // Act render( <ChunkCardList chunkType={ChunkingMode.text} @@ -769,20 +644,16 @@ describe('ChunkCardList', () => { />, ) - // Assert - position IDs should be 1, 2, 3 expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() expect(screen.getByText(/Chunk-02/)).toBeInTheDocument() expect(screen.getByText(/Chunk-03/)).toBeInTheDocument() }) }) - // Tests for className prop describe('Custom className', () => { it('should apply custom className to container', () => { - // Arrange const chunks = createGeneralChunks([{ content: 'Test' }]) - // Act const { container } = render( <ChunkCardList chunkType={ChunkingMode.text} @@ -791,15 +662,12 @@ describe('ChunkCardList', () => { />, ) - // Assert expect(container.firstChild).toHaveClass('custom-class') }) it('should merge custom className with default classes', () => { - // Arrange const chunks = createGeneralChunks([{ content: 'Test' }]) - // Act const { container } = render( <ChunkCardList chunkType={ChunkingMode.text} @@ -808,7 +676,6 @@ describe('ChunkCardList', () => { />, ) - // Assert - should have both default and custom classes expect(container.firstChild).toHaveClass('flex') expect(container.firstChild).toHaveClass('w-full') expect(container.firstChild).toHaveClass('flex-col') @@ -816,10 +683,8 @@ describe('ChunkCardList', () => { }) it('should render without className prop', () => { - // Arrange const chunks = createGeneralChunks([{ content: 'Test' }]) - // Act const { container } = render( <ChunkCardList chunkType={ChunkingMode.text} @@ -827,19 +692,15 @@ describe('ChunkCardList', () => { />, ) - // Assert - should have default classes expect(container.firstChild).toHaveClass('flex') expect(container.firstChild).toHaveClass('w-full') }) }) - // Tests for parentMode prop describe('Parent Mode', () => { it('should pass parentMode to ChunkCard for parent-child type', () => { - // Arrange const chunks = createParentChildChunks() - // Act render( <ChunkCardList chunkType={ChunkingMode.parentChild} @@ -848,15 +709,12 @@ describe('ChunkCardList', () => { />, ) - // Assert - paragraph mode shows Parent-Chunk label expect(screen.getAllByText(/Parent-Chunk/).length).toBeGreaterThan(0) }) it('should handle full-doc parentMode', () => { - // Arrange const chunks = createParentChildChunks() - // Act render( <ChunkCardList chunkType={ChunkingMode.parentChild} @@ -865,16 +723,13 @@ describe('ChunkCardList', () => { />, ) - // Assert - full-doc mode hides chunk labels expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument() expect(screen.queryByText(/Chunk-/)).not.toBeInTheDocument() }) it('should not use parentMode for text type', () => { - // Arrange const chunks = createGeneralChunks([{ content: 'Text' }]) - // Act render( <ChunkCardList chunkType={ChunkingMode.text} @@ -883,18 +738,14 @@ describe('ChunkCardList', () => { />, ) - // Assert - should show Chunk label, not affected by parentMode expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() }) }) - // Tests for edge cases describe('Edge Cases', () => { it('should handle empty GeneralChunks array', () => { - // Arrange const chunks: GeneralChunks = [] - // Act const { container } = render( <ChunkCardList chunkType={ChunkingMode.text} @@ -902,19 +753,16 @@ describe('ChunkCardList', () => { />, ) - // Assert - should render empty container expect(container.firstChild).toBeInTheDocument() expect(container.firstChild?.childNodes.length).toBe(0) }) it('should handle empty ParentChildChunks', () => { - // Arrange const chunks: ParentChildChunks = { parent_child_chunks: [], parent_mode: 'paragraph', } - // Act const { container } = render( <ChunkCardList chunkType={ChunkingMode.parentChild} @@ -923,18 +771,15 @@ describe('ChunkCardList', () => { />, ) - // Assert expect(container.firstChild).toBeInTheDocument() expect(container.firstChild?.childNodes.length).toBe(0) }) it('should handle empty QAChunks', () => { - // Arrange const chunks: QAChunks = { qa_chunks: [], } - // Act const { container } = render( <ChunkCardList chunkType={ChunkingMode.qa} @@ -942,16 +787,13 @@ describe('ChunkCardList', () => { />, ) - // Assert expect(container.firstChild).toBeInTheDocument() expect(container.firstChild?.childNodes.length).toBe(0) }) it('should handle single item in chunks', () => { - // Arrange const chunks = createGeneralChunks([{ content: 'Single chunk' }]) - // Act render( <ChunkCardList chunkType={ChunkingMode.text} @@ -959,16 +801,13 @@ describe('ChunkCardList', () => { />, ) - // Assert expect(screen.getByText('Single chunk')).toBeInTheDocument() expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() }) it('should handle large number of chunks', () => { - // Arrange const chunks = Array.from({ length: 100 }, (_, i) => ({ content: `Chunk number ${i + 1}` })) - // Act render( <ChunkCardList chunkType={ChunkingMode.text} @@ -976,23 +815,19 @@ describe('ChunkCardList', () => { />, ) - // Assert expect(screen.getByText('Chunk number 1')).toBeInTheDocument() expect(screen.getByText('Chunk number 100')).toBeInTheDocument() expect(screen.getByText(/Chunk-100/)).toBeInTheDocument() }) }) - // Tests for key uniqueness describe('Key Generation', () => { it('should generate unique keys for chunks', () => { - // Arrange - chunks with same content const chunks = createGeneralChunks([ { content: 'Same content' }, { content: 'Same content' }, { content: 'Same content' }, ]) - // Act const { container } = render( <ChunkCardList chunkType={ChunkingMode.text} @@ -1000,33 +835,25 @@ describe('ChunkCardList', () => { />, ) - // Assert - all three should render (keys are based on chunkType-index) const chunkCards = container.querySelectorAll('.bg-components-panel-bg') expect(chunkCards.length).toBe(3) }) }) }) -// ============================================================================= -// Integration Tests -// ============================================================================= - describe('ChunkCardList Integration', () => { beforeEach(() => { vi.clearAllMocks() }) - // Tests for complete workflow scenarios describe('Complete Workflows', () => { it('should render complete text chunking workflow', () => { - // Arrange const textChunks = createGeneralChunks([ { content: 'First paragraph of the document.' }, { content: 'Second paragraph with more information.' }, { content: 'Final paragraph concluding the content.' }, ]) - // Act render( <ChunkCardList chunkType={ChunkingMode.text} @@ -1034,10 +861,8 @@ describe('ChunkCardList Integration', () => { />, ) - // Assert expect(screen.getByText('First paragraph of the document.')).toBeInTheDocument() expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() - // "First paragraph of the document." = 32 characters expect(screen.getByText(/32\s+(?:\S.*)?characters/)).toBeInTheDocument() expect(screen.getByText('Second paragraph with more information.')).toBeInTheDocument() @@ -1048,7 +873,6 @@ describe('ChunkCardList Integration', () => { }) it('should render complete parent-child chunking workflow', () => { - // Arrange const parentChildChunks = createParentChildChunks({ parent_child_chunks: [ { @@ -1062,7 +886,6 @@ describe('ChunkCardList Integration', () => { ], }) - // Act render( <ChunkCardList chunkType={ChunkingMode.parentChild} @@ -1071,7 +894,6 @@ describe('ChunkCardList Integration', () => { />, ) - // Assert expect(screen.getByText('React components are building blocks.')).toBeInTheDocument() expect(screen.getByText('Lifecycle methods control component behavior.')).toBeInTheDocument() expect(screen.getByText('C-1')).toBeInTheDocument() @@ -1080,7 +902,6 @@ describe('ChunkCardList Integration', () => { }) it('should render complete QA chunking workflow', () => { - // Arrange const qaChunks = createQAChunks({ qa_chunks: [ { @@ -1094,7 +915,6 @@ describe('ChunkCardList Integration', () => { ], }) - // Act render( <ChunkCardList chunkType={ChunkingMode.qa} @@ -1102,7 +922,6 @@ describe('ChunkCardList Integration', () => { />, ) - // Assert const qLabels = screen.getAllByText('Q') const aLabels = screen.getAllByText('A') expect(qLabels.length).toBe(2) @@ -1115,10 +934,8 @@ describe('ChunkCardList Integration', () => { }) }) - // Tests for type switching scenarios describe('Type Switching', () => { it('should handle switching from text to QA type', () => { - // Arrange const textChunks = createGeneralChunks([{ content: 'Text content' }]) const qaChunks = createQAChunks() @@ -1129,10 +946,8 @@ describe('ChunkCardList Integration', () => { />, ) - // Assert initial text state expect(screen.getByText('Text content')).toBeInTheDocument() - // Act - switch to QA rerender( <ChunkCardList chunkType={ChunkingMode.qa} @@ -1140,13 +955,11 @@ describe('ChunkCardList Integration', () => { />, ) - // Assert QA state expect(screen.queryByText('Text content')).not.toBeInTheDocument() expect(screen.getByText('What is the answer to life?')).toBeInTheDocument() }) it('should handle switching from text to parent-child type', () => { - // Arrange const textChunks = createGeneralChunks([{ content: 'Simple text' }]) const parentChildChunks = createParentChildChunks() @@ -1157,11 +970,9 @@ describe('ChunkCardList Integration', () => { />, ) - // Assert initial state expect(screen.getByText('Simple text')).toBeInTheDocument() expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() - // Act - switch to parent-child rerender( <ChunkCardList chunkType={ChunkingMode.parentChild} @@ -1170,9 +981,7 @@ describe('ChunkCardList Integration', () => { />, ) - // Assert parent-child state expect(screen.queryByText('Simple text')).not.toBeInTheDocument() - // Multiple Parent-Chunk elements exist, so use getAllByText expect(screen.getAllByText(/Parent-Chunk/).length).toBeGreaterThan(0) }) }) diff --git a/web/app/components/rag-pipeline/components/panel/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx similarity index 76% rename from web/app/components/rag-pipeline/components/panel/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx index 11f9f8b2c4..f651b16697 100644 --- a/web/app/components/rag-pipeline/components/panel/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx @@ -1,13 +1,8 @@ import type { PanelProps } from '@/app/components/workflow/panel' import { render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import RagPipelinePanel from './index' +import RagPipelinePanel from '../index' -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock reactflow to avoid zustand provider error vi.mock('reactflow', () => ({ useNodes: () => [], useStoreApi: () => ({ @@ -26,20 +21,12 @@ vi.mock('reactflow', () => ({ }, })) -// Use vi.hoisted to create variables that can be used in vi.mock const { dynamicMocks, mockInputFieldEditorProps } = vi.hoisted(() => { let counter = 0 const mockInputFieldEditorProps = vi.fn() const createMockComponent = () => { const index = counter++ - // Order matches the imports in index.tsx: - // 0: Record - // 1: TestRunPanel - // 2: InputFieldPanel - // 3: InputFieldEditorPanel - // 4: PreviewPanel - // 5: GlobalVariablePanel switch (index) { case 0: return () => <div data-testid="record-panel">Record Panel</div> @@ -69,14 +56,12 @@ const { dynamicMocks, mockInputFieldEditorProps } = vi.hoisted(() => { return { dynamicMocks: { createMockComponent }, mockInputFieldEditorProps } }) -// Mock next/dynamic vi.mock('next/dynamic', () => ({ default: (_loader: () => Promise<{ default: React.ComponentType }>, _options?: Record<string, unknown>) => { return dynamicMocks.createMockComponent() }, })) -// Mock workflow store let mockHistoryWorkflowData: Record<string, unknown> | null = null let mockShowDebugAndPreviewPanel = false let mockShowGlobalVariablePanel = false @@ -138,7 +123,6 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock Panel component to capture props and render children let capturedPanelProps: PanelProps | null = null vi.mock('@/app/components/workflow/panel', () => ({ default: (props: PanelProps) => { @@ -152,10 +136,6 @@ vi.mock('@/app/components/workflow/panel', () => ({ }, })) -// ============================================================================ -// Helper Functions -// ============================================================================ - type SetupMockOptions = { historyWorkflowData?: Record<string, unknown> | null showDebugAndPreviewPanel?: boolean @@ -177,35 +157,24 @@ const setupMocks = (options?: SetupMockOptions) => { capturedPanelProps = null } -// ============================================================================ -// RagPipelinePanel Component Tests -// ============================================================================ - describe('RagPipelinePanel', () => { beforeEach(() => { vi.clearAllMocks() setupMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', async () => { - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('workflow-panel')).toBeInTheDocument() }) }) it('should render Panel component with correct structure', async () => { - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('panel-left')).toBeInTheDocument() expect(screen.getByTestId('panel-right')).toBeInTheDocument() @@ -213,13 +182,10 @@ describe('RagPipelinePanel', () => { }) it('should pass versionHistoryPanelProps to Panel', async () => { - // Arrange setupMocks({ pipelineId: 'my-pipeline-456' }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(capturedPanelProps?.versionHistoryPanelProps).toBeDefined() expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe( @@ -229,18 +195,12 @@ describe('RagPipelinePanel', () => { }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - versionHistoryPanelProps - // ------------------------------------------------------------------------- describe('Memoization - versionHistoryPanelProps', () => { it('should compute correct getVersionListUrl based on pipelineId', async () => { - // Arrange setupMocks({ pipelineId: 'pipeline-abc' }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe( '/rag/pipelines/pipeline-abc/workflows', @@ -249,13 +209,10 @@ describe('RagPipelinePanel', () => { }) it('should compute correct deleteVersionUrl function', async () => { - // Arrange setupMocks({ pipelineId: 'pipeline-xyz' }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { const deleteUrl = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1') expect(deleteUrl).toBe('/rag/pipelines/pipeline-xyz/workflows/version-1') @@ -263,13 +220,10 @@ describe('RagPipelinePanel', () => { }) it('should compute correct updateVersionUrl function', async () => { - // Arrange setupMocks({ pipelineId: 'pipeline-def' }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { const updateUrl = capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-2') expect(updateUrl).toBe('/rag/pipelines/pipeline-def/workflows/version-2') @@ -277,63 +231,46 @@ describe('RagPipelinePanel', () => { }) it('should set latestVersionId to empty string', async () => { - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('') }) }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - panelProps - // ------------------------------------------------------------------------- describe('Memoization - panelProps', () => { it('should pass components.left to Panel', async () => { - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(capturedPanelProps?.components?.left).toBeDefined() }) }) it('should pass components.right to Panel', async () => { - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(capturedPanelProps?.components?.right).toBeDefined() }) }) it('should pass versionHistoryPanelProps to panelProps', async () => { - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(capturedPanelProps?.versionHistoryPanelProps).toBeDefined() }) }) }) - // ------------------------------------------------------------------------- - // Component Memoization Tests (React.memo) - // ------------------------------------------------------------------------- describe('Component Memoization', () => { it('should be wrapped with React.memo', async () => { - // The component should not break when re-rendered const { rerender } = render(<RagPipelinePanel />) - // Act - rerender without prop changes rerender(<RagPipelinePanel />) - // Assert - component should still render correctly await waitFor(() => { expect(screen.getByTestId('workflow-panel')).toBeInTheDocument() }) @@ -341,138 +278,98 @@ describe('RagPipelinePanel', () => { }) }) -// ============================================================================ -// RagPipelinePanelOnRight Component Tests -// ============================================================================ - describe('RagPipelinePanelOnRight', () => { beforeEach(() => { vi.clearAllMocks() setupMocks() }) - // ------------------------------------------------------------------------- - // Conditional Rendering - Record Panel - // ------------------------------------------------------------------------- describe('Record Panel Conditional Rendering', () => { it('should render Record panel when historyWorkflowData exists', async () => { - // Arrange setupMocks({ historyWorkflowData: { id: 'history-1' } }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('record-panel')).toBeInTheDocument() }) }) it('should not render Record panel when historyWorkflowData is null', async () => { - // Arrange setupMocks({ historyWorkflowData: null }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument() }) }) it('should not render Record panel when historyWorkflowData is undefined', async () => { - // Arrange setupMocks({ historyWorkflowData: undefined }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument() }) }) }) - // ------------------------------------------------------------------------- - // Conditional Rendering - TestRun Panel - // ------------------------------------------------------------------------- describe('TestRun Panel Conditional Rendering', () => { it('should render TestRun panel when showDebugAndPreviewPanel is true', async () => { - // Arrange setupMocks({ showDebugAndPreviewPanel: true }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('test-run-panel')).toBeInTheDocument() }) }) it('should not render TestRun panel when showDebugAndPreviewPanel is false', async () => { - // Arrange setupMocks({ showDebugAndPreviewPanel: false }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.queryByTestId('test-run-panel')).not.toBeInTheDocument() }) }) }) - // ------------------------------------------------------------------------- - // Conditional Rendering - GlobalVariable Panel - // ------------------------------------------------------------------------- describe('GlobalVariable Panel Conditional Rendering', () => { it('should render GlobalVariable panel when showGlobalVariablePanel is true', async () => { - // Arrange setupMocks({ showGlobalVariablePanel: true }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument() }) }) it('should not render GlobalVariable panel when showGlobalVariablePanel is false', async () => { - // Arrange setupMocks({ showGlobalVariablePanel: false }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.queryByTestId('global-variable-panel')).not.toBeInTheDocument() }) }) }) - // ------------------------------------------------------------------------- - // Multiple Panels Rendering - // ------------------------------------------------------------------------- describe('Multiple Panels Rendering', () => { it('should render all right panels when all conditions are true', async () => { - // Arrange setupMocks({ historyWorkflowData: { id: 'history-1' }, showDebugAndPreviewPanel: true, showGlobalVariablePanel: true, }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('record-panel')).toBeInTheDocument() expect(screen.getByTestId('test-run-panel')).toBeInTheDocument() @@ -481,17 +378,14 @@ describe('RagPipelinePanelOnRight', () => { }) it('should render no right panels when all conditions are false', async () => { - // Arrange setupMocks({ historyWorkflowData: null, showDebugAndPreviewPanel: false, showGlobalVariablePanel: false, }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument() expect(screen.queryByTestId('test-run-panel')).not.toBeInTheDocument() @@ -500,17 +394,14 @@ describe('RagPipelinePanelOnRight', () => { }) it('should render only Record and TestRun panels', async () => { - // Arrange setupMocks({ historyWorkflowData: { id: 'history-1' }, showDebugAndPreviewPanel: true, showGlobalVariablePanel: false, }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('record-panel')).toBeInTheDocument() expect(screen.getByTestId('test-run-panel')).toBeInTheDocument() @@ -520,53 +411,36 @@ describe('RagPipelinePanelOnRight', () => { }) }) -// ============================================================================ -// RagPipelinePanelOnLeft Component Tests -// ============================================================================ - describe('RagPipelinePanelOnLeft', () => { beforeEach(() => { vi.clearAllMocks() setupMocks() }) - // ------------------------------------------------------------------------- - // Conditional Rendering - Preview Panel - // ------------------------------------------------------------------------- describe('Preview Panel Conditional Rendering', () => { it('should render Preview panel when showInputFieldPreviewPanel is true', async () => { - // Arrange setupMocks({ showInputFieldPreviewPanel: true }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('preview-panel')).toBeInTheDocument() }) }) it('should not render Preview panel when showInputFieldPreviewPanel is false', async () => { - // Arrange setupMocks({ showInputFieldPreviewPanel: false }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.queryByTestId('preview-panel')).not.toBeInTheDocument() }) }) }) - // ------------------------------------------------------------------------- - // Conditional Rendering - InputFieldEditor Panel - // ------------------------------------------------------------------------- describe('InputFieldEditor Panel Conditional Rendering', () => { it('should render InputFieldEditor panel when inputFieldEditPanelProps is provided', async () => { - // Arrange const editProps = { onClose: vi.fn(), onSubmit: vi.fn(), @@ -574,30 +448,24 @@ describe('RagPipelinePanelOnLeft', () => { } setupMocks({ inputFieldEditPanelProps: editProps }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('input-field-editor-panel')).toBeInTheDocument() }) }) it('should not render InputFieldEditor panel when inputFieldEditPanelProps is null', async () => { - // Arrange setupMocks({ inputFieldEditPanelProps: null }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument() }) }) it('should pass props to InputFieldEditor panel', async () => { - // Arrange const editProps = { onClose: vi.fn(), onSubmit: vi.fn(), @@ -605,10 +473,8 @@ describe('RagPipelinePanelOnLeft', () => { } setupMocks({ inputFieldEditPanelProps: editProps }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(mockInputFieldEditorProps).toHaveBeenCalledWith( expect.objectContaining({ @@ -621,53 +487,38 @@ describe('RagPipelinePanelOnLeft', () => { }) }) - // ------------------------------------------------------------------------- - // Conditional Rendering - InputField Panel - // ------------------------------------------------------------------------- describe('InputField Panel Conditional Rendering', () => { it('should render InputField panel when showInputFieldPanel is true', async () => { - // Arrange setupMocks({ showInputFieldPanel: true }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('input-field-panel')).toBeInTheDocument() }) }) it('should not render InputField panel when showInputFieldPanel is false', async () => { - // Arrange setupMocks({ showInputFieldPanel: false }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.queryByTestId('input-field-panel')).not.toBeInTheDocument() }) }) }) - // ------------------------------------------------------------------------- - // Multiple Panels Rendering - // ------------------------------------------------------------------------- describe('Multiple Left Panels Rendering', () => { it('should render all left panels when all conditions are true', async () => { - // Arrange setupMocks({ showInputFieldPreviewPanel: true, inputFieldEditPanelProps: { onClose: vi.fn(), onSubmit: vi.fn() }, showInputFieldPanel: true, }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('preview-panel')).toBeInTheDocument() expect(screen.getByTestId('input-field-editor-panel')).toBeInTheDocument() @@ -676,17 +527,14 @@ describe('RagPipelinePanelOnLeft', () => { }) it('should render no left panels when all conditions are false', async () => { - // Arrange setupMocks({ showInputFieldPreviewPanel: false, inputFieldEditPanelProps: null, showInputFieldPanel: false, }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.queryByTestId('preview-panel')).not.toBeInTheDocument() expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument() @@ -695,17 +543,14 @@ describe('RagPipelinePanelOnLeft', () => { }) it('should render only Preview and InputField panels', async () => { - // Arrange setupMocks({ showInputFieldPreviewPanel: true, inputFieldEditPanelProps: null, showInputFieldPanel: true, }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(screen.getByTestId('preview-panel')).toBeInTheDocument() expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument() @@ -715,28 +560,18 @@ describe('RagPipelinePanelOnLeft', () => { }) }) -// ============================================================================ -// Edge Cases Tests -// ============================================================================ - describe('Edge Cases', () => { beforeEach(() => { vi.clearAllMocks() setupMocks() }) - // ------------------------------------------------------------------------- - // Empty/Undefined Values - // ------------------------------------------------------------------------- describe('Empty/Undefined Values', () => { it('should handle empty pipelineId gracefully', async () => { - // Arrange setupMocks({ pipelineId: '' }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe( '/rag/pipelines//workflows', @@ -745,13 +580,10 @@ describe('Edge Cases', () => { }) it('should handle special characters in pipelineId', async () => { - // Arrange setupMocks({ pipelineId: 'pipeline-with-special_chars.123' }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe( '/rag/pipelines/pipeline-with-special_chars.123/workflows', @@ -760,12 +592,8 @@ describe('Edge Cases', () => { }) }) - // ------------------------------------------------------------------------- - // Props Spreading Tests - // ------------------------------------------------------------------------- describe('Props Spreading', () => { it('should correctly spread inputFieldEditPanelProps to editor component', async () => { - // Arrange const customProps = { onClose: vi.fn(), onSubmit: vi.fn(), @@ -778,10 +606,8 @@ describe('Edge Cases', () => { } setupMocks({ inputFieldEditPanelProps: customProps }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(mockInputFieldEditorProps).toHaveBeenCalledWith( expect.objectContaining({ @@ -792,12 +618,8 @@ describe('Edge Cases', () => { }) }) - // ------------------------------------------------------------------------- - // State Combinations - // ------------------------------------------------------------------------- describe('State Combinations', () => { it('should handle all panels visible simultaneously', async () => { - // Arrange setupMocks({ historyWorkflowData: { id: 'h1' }, showDebugAndPreviewPanel: true, @@ -807,10 +629,8 @@ describe('Edge Cases', () => { showInputFieldPanel: true, }) - // Act render(<RagPipelinePanel />) - // Assert - All panels should be visible await waitFor(() => { expect(screen.getByTestId('record-panel')).toBeInTheDocument() expect(screen.getByTestId('test-run-panel')).toBeInTheDocument() @@ -823,10 +643,6 @@ describe('Edge Cases', () => { }) }) -// ============================================================================ -// URL Generator Functions Tests -// ============================================================================ - describe('URL Generator Functions', () => { beforeEach(() => { vi.clearAllMocks() @@ -834,13 +650,10 @@ describe('URL Generator Functions', () => { }) it('should return consistent URLs for same versionId', async () => { - // Arrange setupMocks({ pipelineId: 'stable-pipeline' }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { const deleteUrl1 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x') const deleteUrl2 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x') @@ -849,13 +662,10 @@ describe('URL Generator Functions', () => { }) it('should return different URLs for different versionIds', async () => { - // Arrange setupMocks({ pipelineId: 'stable-pipeline' }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { const deleteUrl1 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1') const deleteUrl2 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-2') @@ -866,10 +676,6 @@ describe('URL Generator Functions', () => { }) }) -// ============================================================================ -// Type Safety Tests -// ============================================================================ - describe('Type Safety', () => { beforeEach(() => { vi.clearAllMocks() @@ -877,10 +683,8 @@ describe('Type Safety', () => { }) it('should pass correct PanelProps structure', async () => { - // Act render(<RagPipelinePanel />) - // Assert - Check structure matches PanelProps await waitFor(() => { expect(capturedPanelProps).toHaveProperty('components') expect(capturedPanelProps).toHaveProperty('versionHistoryPanelProps') @@ -890,10 +694,8 @@ describe('Type Safety', () => { }) it('should pass correct versionHistoryPanelProps structure', async () => { - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(capturedPanelProps?.versionHistoryPanelProps).toHaveProperty('getVersionListUrl') expect(capturedPanelProps?.versionHistoryPanelProps).toHaveProperty('deleteVersionUrl') @@ -903,10 +705,6 @@ describe('Type Safety', () => { }) }) -// ============================================================================ -// Performance Tests -// ============================================================================ - describe('Performance', () => { beforeEach(() => { vi.clearAllMocks() @@ -914,24 +712,17 @@ describe('Performance', () => { }) it('should handle multiple rerenders without issues', async () => { - // Arrange const { rerender } = render(<RagPipelinePanel />) - // Act - Multiple rerenders for (let i = 0; i < 10; i++) rerender(<RagPipelinePanel />) - // Assert - Component should still work await waitFor(() => { expect(screen.getByTestId('workflow-panel')).toBeInTheDocument() }) }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('Integration Tests', () => { beforeEach(() => { vi.clearAllMocks() @@ -939,28 +730,23 @@ describe('Integration Tests', () => { }) it('should pass correct components to Panel', async () => { - // Arrange setupMocks({ historyWorkflowData: { id: 'h1' }, showInputFieldPanel: true, }) - // Act render(<RagPipelinePanel />) - // Assert await waitFor(() => { expect(capturedPanelProps?.components?.left).toBeDefined() expect(capturedPanelProps?.components?.right).toBeDefined() - // Check that the components are React elements expect(React.isValidElement(capturedPanelProps?.components?.left)).toBe(true) expect(React.isValidElement(capturedPanelProps?.components?.right)).toBe(true) }) }) it('should correctly consume all store selectors', async () => { - // Arrange setupMocks({ historyWorkflowData: { id: 'test-history' }, showDebugAndPreviewPanel: true, @@ -971,10 +757,8 @@ describe('Integration Tests', () => { pipelineId: 'integration-test-pipeline', }) - // Act render(<RagPipelinePanel />) - // Assert - All store-dependent rendering should work await waitFor(() => { expect(screen.getByTestId('record-panel')).toBeInTheDocument() expect(screen.getByTestId('test-run-panel')).toBeInTheDocument() diff --git a/web/app/components/rag-pipeline/components/panel/input-field/footer-tip.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/footer-tip.spec.tsx similarity index 94% rename from web/app/components/rag-pipeline/components/panel/input-field/footer-tip.spec.tsx rename to web/app/components/rag-pipeline/components/panel/input-field/__tests__/footer-tip.spec.tsx index 5d5cde9735..f70b9a4a6f 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/footer-tip.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/footer-tip.spec.tsx @@ -1,6 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import FooterTip from './footer-tip' +import FooterTip from '../footer-tip' afterEach(() => { cleanup() @@ -45,7 +45,6 @@ describe('FooterTip', () => { it('should render the drag icon', () => { const { container } = render(<FooterTip />) - // The RiDragDropLine icon should be rendered const icon = container.querySelector('.size-4') expect(icon).toBeInTheDocument() }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts similarity index 87% rename from web/app/components/rag-pipeline/components/panel/input-field/hooks.spec.ts rename to web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts index 452963ba7f..9f7fb7e902 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/hooks.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts @@ -1,8 +1,7 @@ import { renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useFloatingRight } from './hooks' +import { useFloatingRight } from '../hooks' -// Mock reactflow const mockGetNodes = vi.fn() vi.mock('reactflow', () => ({ useStore: (selector: (s: { getNodes: () => { id: string, data: { selected: boolean } }[] }) => unknown) => { @@ -10,12 +9,10 @@ vi.mock('reactflow', () => ({ }, })) -// Mock zustand/react/shallow vi.mock('zustand/react/shallow', () => ({ useShallow: (fn: (...args: unknown[]) => unknown) => fn, })) -// Mock workflow store let mockNodePanelWidth = 400 let mockWorkflowCanvasWidth: number | undefined = 1200 let mockOtherPanelWidth = 0 @@ -67,8 +64,6 @@ describe('useFloatingRight', () => { const { result } = renderHook(() => useFloatingRight(400)) - // leftWidth = 1000 - 0 (no selected node) - 0 - 400 - 4 = 596 - // 596 >= 404 so floatingRight should be false expect(result.current.floatingRight).toBe(false) }) }) @@ -80,8 +75,6 @@ describe('useFloatingRight', () => { const { result } = renderHook(() => useFloatingRight(400)) - // leftWidth = 1200 - 400 (node panel) - 0 - 400 - 4 = 396 - // 396 < 404 so floatingRight should be true expect(result.current.floatingRight).toBe(true) }) }) @@ -103,7 +96,6 @@ describe('useFloatingRight', () => { const { result } = renderHook(() => useFloatingRight(600)) - // When floating and no selected node, width = min(600, 0 + 200) = 200 expect(result.current.floatingRightWidth).toBeLessThanOrEqual(600) }) @@ -115,7 +107,6 @@ describe('useFloatingRight', () => { const { result } = renderHook(() => useFloatingRight(600)) - // When floating with selected node, width = min(600, 300 + 100) = 400 expect(result.current.floatingRightWidth).toBeLessThanOrEqual(600) }) }) @@ -127,7 +118,6 @@ describe('useFloatingRight', () => { const { result } = renderHook(() => useFloatingRight(400)) - // Should not throw and should maintain initial state expect(result.current.floatingRight).toBe(false) }) @@ -145,7 +135,6 @@ describe('useFloatingRight', () => { const { result } = renderHook(() => useFloatingRight(10000)) - // Should be floating due to limited space expect(result.current.floatingRight).toBe(true) }) @@ -159,7 +148,6 @@ describe('useFloatingRight', () => { const { result } = renderHook(() => useFloatingRight(400)) - // Should have selected node so node panel is considered expect(result.current).toBeDefined() }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx similarity index 78% rename from web/app/components/rag-pipeline/components/panel/input-field/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx index 0ff7a06dae..ab99a1eeef 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx @@ -5,19 +5,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { BlockEnum } from '@/app/components/workflow/types' import { PipelineInputVarType } from '@/models/pipeline' -import InputFieldPanel from './index' +import InputFieldPanel from '../index' -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock reactflow hooks - use getter to allow dynamic updates let mockNodesData: Node<DataSourceNodeType>[] = [] vi.mock('reactflow', () => ({ useNodes: () => mockNodesData, })) -// Mock useInputFieldPanel hook const mockCloseAllInputFieldPanels = vi.fn() const mockToggleInputFieldPreviewPanel = vi.fn() let mockIsPreviewing = false @@ -32,7 +26,6 @@ vi.mock('@/app/components/rag-pipeline/hooks', () => ({ }), })) -// Mock useStore (workflow store) let mockRagPipelineVariables: RAGPipelineVariables = [] const mockSetRagPipelineVariables = vi.fn() @@ -56,7 +49,6 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock useNodesSyncDraft hook const mockHandleSyncWorkflowDraft = vi.fn() vi.mock('@/app/components/workflow/hooks', () => ({ @@ -65,8 +57,7 @@ vi.mock('@/app/components/workflow/hooks', () => ({ }), })) -// Mock FieldList component -vi.mock('./field-list', () => ({ +vi.mock('../field-list', () => ({ default: ({ nodeId, LabelRightContent, @@ -124,13 +115,11 @@ vi.mock('./field-list', () => ({ ), })) -// Mock FooterTip component -vi.mock('./footer-tip', () => ({ +vi.mock('../footer-tip', () => ({ default: () => <div data-testid="footer-tip">Footer Tip</div>, })) -// Mock Datasource label component -vi.mock('./label-right-content/datasource', () => ({ +vi.mock('../label-right-content/datasource', () => ({ default: ({ nodeData }: { nodeData: DataSourceNodeType }) => ( <div data-testid={`datasource-label-${nodeData.title}`}> {nodeData.title} @@ -138,15 +127,10 @@ vi.mock('./label-right-content/datasource', () => ({ ), })) -// Mock GlobalInputs label component -vi.mock('./label-right-content/global-inputs', () => ({ +vi.mock('../label-right-content/global-inputs', () => ({ default: () => <div data-testid="global-inputs-label">Global Inputs</div>, })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createInputVar = (overrides?: Partial<InputVar>): InputVar => ({ type: PipelineInputVarType.textInput, label: 'Test Label', @@ -189,10 +173,6 @@ const createDataSourceNode = ( } as DataSourceNodeType, }) -// ============================================================================ -// Helper Functions -// ============================================================================ - const setupMocks = (options?: { nodes?: Node<DataSourceNodeType>[] ragPipelineVariables?: RAGPipelineVariables @@ -205,148 +185,110 @@ const setupMocks = (options?: { mockIsEditing = options?.isEditing || false } -// ============================================================================ -// InputFieldPanel Component Tests -// ============================================================================ - describe('InputFieldPanel', () => { beforeEach(() => { vi.clearAllMocks() setupMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render panel without crashing', () => { - // Act render(<InputFieldPanel />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.title'), ).toBeInTheDocument() }) it('should render panel title correctly', () => { - // Act render(<InputFieldPanel />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.title'), ).toBeInTheDocument() }) it('should render panel description', () => { - // Act render(<InputFieldPanel />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.description'), ).toBeInTheDocument() }) it('should render preview button', () => { - // Act render(<InputFieldPanel />) - // Assert expect( screen.getByText('datasetPipeline.operations.preview'), ).toBeInTheDocument() }) it('should render close button', () => { - // Act render(<InputFieldPanel />) - // Assert const closeButton = screen.getByRole('button', { name: '' }) expect(closeButton).toBeInTheDocument() }) it('should render footer tip component', () => { - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('footer-tip')).toBeInTheDocument() }) it('should render unique inputs section title', () => { - // Act render(<InputFieldPanel />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.uniqueInputs.title'), ).toBeInTheDocument() }) it('should render global inputs field list', () => { - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-shared')).toBeInTheDocument() expect(screen.getByTestId('global-inputs-label')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // DataSource Node Rendering Tests - // ------------------------------------------------------------------------- describe('DataSource Node Rendering', () => { it('should render field list for each datasource node', () => { - // Arrange const nodes = [ createDataSourceNode('node-1', 'DataSource 1'), createDataSourceNode('node-2', 'DataSource 2'), ] setupMocks({ nodes }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-node-1')).toBeInTheDocument() expect(screen.getByTestId('field-list-node-2')).toBeInTheDocument() }) it('should render datasource label for each node', () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'My DataSource')] setupMocks({ nodes }) - // Act render(<InputFieldPanel />) - // Assert expect( screen.getByTestId('datasource-label-My DataSource'), ).toBeInTheDocument() }) it('should not render any datasource field lists when no nodes exist', () => { - // Arrange setupMocks({ nodes: [] }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.queryByTestId('field-list-node-1')).not.toBeInTheDocument() - // Global inputs should still render expect(screen.getByTestId('field-list-shared')).toBeInTheDocument() }) it('should filter only DataSource type nodes', () => { - // Arrange const dataSourceNode = createDataSourceNode('ds-node', 'DataSource Node') - // Create a non-datasource node to verify filtering const otherNode = { id: 'other-node', type: 'custom', @@ -359,10 +301,8 @@ describe('InputFieldPanel', () => { } as Node<DataSourceNodeType> mockNodesData = [dataSourceNode, otherNode] - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-ds-node')).toBeInTheDocument() expect( screen.queryByTestId('field-list-other-node'), @@ -370,12 +310,8 @@ describe('InputFieldPanel', () => { }) }) - // ------------------------------------------------------------------------- - // Input Fields Map Tests - // ------------------------------------------------------------------------- describe('Input Fields Map', () => { it('should correctly distribute variables to their nodes', () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] const variables = [ createRAGPipelineVariable('node-1', { variable: 'var1' }), @@ -384,28 +320,22 @@ describe('InputFieldPanel', () => { ] setupMocks({ nodes, ragPipelineVariables: variables }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('2') expect(screen.getByTestId('field-list-fields-count-shared')).toHaveTextContent('1') }) it('should show zero fields for nodes without variables', () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] setupMocks({ nodes, ragPipelineVariables: [] }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('0') }) it('should pass all variable names to field lists', () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] const variables = [ createRAGPipelineVariable('node-1', { variable: 'var1' }), @@ -413,10 +343,8 @@ describe('InputFieldPanel', () => { ] setupMocks({ nodes, ragPipelineVariables: variables }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-all-vars-node-1')).toHaveTextContent( 'var1,var2', ) @@ -426,48 +354,35 @@ describe('InputFieldPanel', () => { }) }) - // ------------------------------------------------------------------------- - // User Interactions Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { - // Helper to identify close button by its class const isCloseButton = (btn: HTMLElement) => btn.classList.contains('size-6') || btn.className.includes('shrink-0 items-center justify-center p-0.5') it('should call closeAllInputFieldPanels when close button is clicked', () => { - // Arrange render(<InputFieldPanel />) const buttons = screen.getAllByRole('button') const closeButton = buttons.find(isCloseButton) - // Act fireEvent.click(closeButton!) - // Assert expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1) }) it('should call toggleInputFieldPreviewPanel when preview button is clicked', () => { - // Arrange render(<InputFieldPanel />) const previewButton = screen.getByText('datasetPipeline.operations.preview') - // Act fireEvent.click(previewButton) - // Assert expect(mockToggleInputFieldPreviewPanel).toHaveBeenCalledTimes(1) }) it('should disable preview button when editing', () => { - // Arrange setupMocks({ isEditing: true }) - // Act render(<InputFieldPanel />) - // Assert const previewButton = screen .getByText('datasetPipeline.operations.preview') .closest('button') @@ -475,13 +390,10 @@ describe('InputFieldPanel', () => { }) it('should not disable preview button when not editing', () => { - // Arrange setupMocks({ isEditing: false }) - // Act render(<InputFieldPanel />) - // Assert const previewButton = screen .getByText('datasetPipeline.operations.preview') .closest('button') @@ -489,18 +401,12 @@ describe('InputFieldPanel', () => { }) }) - // ------------------------------------------------------------------------- - // Preview State Tests - // ------------------------------------------------------------------------- describe('Preview State', () => { it('should apply active styling when previewing', () => { - // Arrange setupMocks({ isPreviewing: true }) - // Act render(<InputFieldPanel />) - // Assert const previewButton = screen .getByText('datasetPipeline.operations.preview') .closest('button') @@ -509,81 +415,62 @@ describe('InputFieldPanel', () => { }) it('should set readonly to true when previewing', () => { - // Arrange setupMocks({ isPreviewing: true }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent( 'true', ) }) it('should set readonly to true when editing', () => { - // Arrange setupMocks({ isEditing: true }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent( 'true', ) }) it('should set readonly to false when not previewing or editing', () => { - // Arrange setupMocks({ isPreviewing: false, isEditing: false }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent( 'false', ) }) }) - // ------------------------------------------------------------------------- - // Input Fields Change Handler Tests - // ------------------------------------------------------------------------- describe('Input Fields Change Handler', () => { it('should update rag pipeline variables when input fields change', async () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] setupMocks({ nodes }) render(<InputFieldPanel />) - // Act fireEvent.click(screen.getByTestId('trigger-change-node-1')) - // Assert await waitFor(() => { expect(mockSetRagPipelineVariables).toHaveBeenCalled() }) }) it('should call handleSyncWorkflowDraft when fields change', async () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] setupMocks({ nodes }) render(<InputFieldPanel />) - // Act fireEvent.click(screen.getByTestId('trigger-change-node-1')) - // Assert await waitFor(() => { expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled() }) }) it('should place datasource node fields before global fields', async () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] const variables = [ createRAGPipelineVariable('shared', { variable: 'shared_var' }), @@ -591,15 +478,12 @@ describe('InputFieldPanel', () => { setupMocks({ nodes, ragPipelineVariables: variables }) render(<InputFieldPanel />) - // Act fireEvent.click(screen.getByTestId('trigger-change-node-1')) - // Assert await waitFor(() => { expect(mockSetRagPipelineVariables).toHaveBeenCalled() }) - // Verify datasource fields come before shared fields const setVarsCall = mockSetRagPipelineVariables.mock.calls[0][0] as RAGPipelineVariables const isNotShared = (v: RAGPipelineVariable) => v.belong_to_node_id !== 'shared' const isShared = (v: RAGPipelineVariable) => v.belong_to_node_id === 'shared' @@ -614,7 +498,6 @@ describe('InputFieldPanel', () => { }) it('should handle removing all fields from a node', async () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] const variables = [ createRAGPipelineVariable('node-1', { variable: 'var1' }), @@ -623,24 +506,19 @@ describe('InputFieldPanel', () => { setupMocks({ nodes, ragPipelineVariables: variables }) render(<InputFieldPanel />) - // Act fireEvent.click(screen.getByTestId('trigger-remove-node-1')) - // Assert await waitFor(() => { expect(mockSetRagPipelineVariables).toHaveBeenCalled() }) }) it('should update global input fields correctly', async () => { - // Arrange setupMocks() render(<InputFieldPanel />) - // Act fireEvent.click(screen.getByTestId('trigger-change-shared')) - // Assert await waitFor(() => { expect(mockSetRagPipelineVariables).toHaveBeenCalled() }) @@ -652,54 +530,39 @@ describe('InputFieldPanel', () => { }) }) - // ------------------------------------------------------------------------- - // Label Class Name Tests - // ------------------------------------------------------------------------- describe('Label Class Names', () => { it('should pass correct className to datasource field lists', () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] setupMocks({ nodes }) - // Act render(<InputFieldPanel />) - // Assert expect( screen.getByTestId('field-list-classname-node-1'), ).toHaveTextContent('pt-1 pb-1') }) it('should pass correct className to global inputs field list', () => { - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-classname-shared')).toHaveTextContent( 'pt-2 pb-1', ) }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should memoize datasourceNodeDataMap based on nodes', () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] setupMocks({ nodes }) const { rerender } = render(<InputFieldPanel />) - // Act - rerender with same nodes reference rerender(<InputFieldPanel />) - // Assert - component should not break and should render correctly expect(screen.getByTestId('field-list-node-1')).toBeInTheDocument() }) it('should compute allVariableNames correctly', () => { - // Arrange const variables = [ createRAGPipelineVariable('node-1', { variable: 'alpha' }), createRAGPipelineVariable('node-1', { variable: 'beta' }), @@ -707,21 +570,15 @@ describe('InputFieldPanel', () => { ] setupMocks({ ragPipelineVariables: variables }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent( 'alpha,beta,gamma', ) }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { - // Helper to find close button - moved outside test to reduce nesting const findCloseButton = (buttons: HTMLElement[]) => { const isCloseButton = (btn: HTMLElement) => btn.classList.contains('size-6') @@ -730,10 +587,8 @@ describe('InputFieldPanel', () => { } it('should maintain closePanel callback reference', () => { - // Arrange const { rerender } = render(<InputFieldPanel />) - // Act const buttons1 = screen.getAllByRole('button') fireEvent.click(findCloseButton(buttons1)!) const callCount1 = mockCloseAllInputFieldPanels.mock.calls.length @@ -742,126 +597,97 @@ describe('InputFieldPanel', () => { const buttons2 = screen.getAllByRole('button') fireEvent.click(findCloseButton(buttons2)!) - // Assert expect(mockCloseAllInputFieldPanels.mock.calls.length).toBe(callCount1 + 1) }) it('should maintain togglePreviewPanel callback reference', () => { - // Arrange const { rerender } = render(<InputFieldPanel />) - // Act fireEvent.click(screen.getByText('datasetPipeline.operations.preview')) const callCount1 = mockToggleInputFieldPreviewPanel.mock.calls.length rerender(<InputFieldPanel />) fireEvent.click(screen.getByText('datasetPipeline.operations.preview')) - // Assert expect(mockToggleInputFieldPreviewPanel.mock.calls.length).toBe( callCount1 + 1, ) }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty ragPipelineVariables', () => { - // Arrange setupMocks({ ragPipelineVariables: [] }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent( '', ) }) it('should handle undefined ragPipelineVariables', () => { - // Arrange - intentionally testing undefined case // @ts-expect-error Testing edge case with undefined value mockRagPipelineVariables = undefined - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-shared')).toBeInTheDocument() }) it('should handle null variable names in allVariableNames', () => { - // Arrange - intentionally testing edge case with empty variable name const variables = [ createRAGPipelineVariable('node-1', { variable: 'valid_var' }), createRAGPipelineVariable('node-1', { variable: '' }), ] setupMocks({ ragPipelineVariables: variables }) - // Act render(<InputFieldPanel />) - // Assert - should not crash expect(screen.getByTestId('field-list-shared')).toBeInTheDocument() }) it('should handle large number of datasource nodes', () => { - // Arrange const nodes = Array.from({ length: 10 }, (_, i) => createDataSourceNode(`node-${i}`, `DataSource ${i}`)) setupMocks({ nodes }) - // Act render(<InputFieldPanel />) - // Assert nodes.forEach((_, i) => { expect(screen.getByTestId(`field-list-node-${i}`)).toBeInTheDocument() }) }) it('should handle large number of variables', () => { - // Arrange const variables = Array.from({ length: 100 }, (_, i) => createRAGPipelineVariable('shared', { variable: `var_${i}` })) setupMocks({ ragPipelineVariables: variables }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-fields-count-shared')).toHaveTextContent( '100', ) }) it('should handle special characters in variable names', () => { - // Arrange const variables = [ createRAGPipelineVariable('shared', { variable: 'var_with_underscore' }), createRAGPipelineVariable('shared', { variable: 'varWithCamelCase' }), ] setupMocks({ ragPipelineVariables: variables }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent( 'var_with_underscore,varWithCamelCase', ) }) }) - // ------------------------------------------------------------------------- - // Multiple Nodes Interaction Tests - // ------------------------------------------------------------------------- describe('Multiple Nodes Interaction', () => { it('should handle changes to multiple nodes sequentially', async () => { - // Arrange const nodes = [ createDataSourceNode('node-1', 'DataSource 1'), createDataSourceNode('node-2', 'DataSource 2'), @@ -869,18 +695,15 @@ describe('InputFieldPanel', () => { setupMocks({ nodes }) render(<InputFieldPanel />) - // Act fireEvent.click(screen.getByTestId('trigger-change-node-1')) fireEvent.click(screen.getByTestId('trigger-change-node-2')) - // Assert await waitFor(() => { expect(mockSetRagPipelineVariables).toHaveBeenCalledTimes(2) }) }) it('should maintain separate field lists for different nodes', () => { - // Arrange const nodes = [ createDataSourceNode('node-1', 'DataSource 1'), createDataSourceNode('node-2', 'DataSource 2'), @@ -892,42 +715,31 @@ describe('InputFieldPanel', () => { ] setupMocks({ nodes, ragPipelineVariables: variables }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('1') expect(screen.getByTestId('field-list-fields-count-node-2')).toHaveTextContent('2') }) }) - // ------------------------------------------------------------------------- - // Component Structure Tests - // ------------------------------------------------------------------------- describe('Component Structure', () => { it('should have correct panel width class', () => { - // Act const { container } = render(<InputFieldPanel />) - // Assert const panel = container.firstChild as HTMLElement expect(panel).toHaveClass('w-[400px]') }) it('should have overflow scroll on content area', () => { - // Act const { container } = render(<InputFieldPanel />) - // Assert const scrollContainer = container.querySelector('.overflow-y-auto') expect(scrollContainer).toBeInTheDocument() }) it('should render header section with proper spacing', () => { - // Act render(<InputFieldPanel />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.title'), ).toBeInTheDocument() @@ -937,12 +749,8 @@ describe('InputFieldPanel', () => { }) }) - // ------------------------------------------------------------------------- - // Integration with FieldList Component Tests - // ------------------------------------------------------------------------- describe('Integration with FieldList Component', () => { it('should pass correct props to FieldList for datasource nodes', () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] const variables = [ createRAGPipelineVariable('node-1', { variable: 'test_var' }), @@ -953,38 +761,29 @@ describe('InputFieldPanel', () => { isPreviewing: true, }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-node-1')).toBeInTheDocument() expect(screen.getByTestId('field-list-readonly-node-1')).toHaveTextContent('true') expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('1') }) it('should pass correct props to FieldList for shared node', () => { - // Arrange const variables = [ createRAGPipelineVariable('shared', { variable: 'shared_var' }), ] setupMocks({ ragPipelineVariables: variables, isEditing: true }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-shared')).toBeInTheDocument() expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent('true') expect(screen.getByTestId('field-list-fields-count-shared')).toHaveTextContent('1') }) }) - // ------------------------------------------------------------------------- - // Variable Ordering Tests - // ------------------------------------------------------------------------- describe('Variable Ordering', () => { it('should maintain correct variable order in allVariableNames', () => { - // Arrange const variables = [ createRAGPipelineVariable('node-1', { variable: 'first' }), createRAGPipelineVariable('node-1', { variable: 'second' }), @@ -992,10 +791,8 @@ describe('InputFieldPanel', () => { ] setupMocks({ ragPipelineVariables: variables }) - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent( 'first,second,third', ) @@ -1003,13 +800,8 @@ describe('InputFieldPanel', () => { }) }) -// ============================================================================ -// useFloatingRight Hook Integration Tests (via InputFieldPanel) -// ============================================================================ - describe('useFloatingRight Hook Integration', () => { // Note: The hook is tested indirectly through the InputFieldPanel component - // as it's used internally. Direct hook tests are in hooks.spec.tsx if exists. beforeEach(() => { vi.clearAllMocks() @@ -1017,16 +809,11 @@ describe('useFloatingRight Hook Integration', () => { }) it('should render panel correctly with default floating state', () => { - // The hook is mocked via the component's behavior render(<InputFieldPanel />) expect(screen.getByTestId('field-list-shared')).toBeInTheDocument() }) }) -// ============================================================================ -// FooterTip Component Integration Tests -// ============================================================================ - describe('FooterTip Integration', () => { beforeEach(() => { vi.clearAllMocks() @@ -1034,18 +821,12 @@ describe('FooterTip Integration', () => { }) it('should render footer tip at the bottom of the panel', () => { - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('footer-tip')).toBeInTheDocument() }) }) -// ============================================================================ -// Label Components Integration Tests -// ============================================================================ - describe('Label Components Integration', () => { beforeEach(() => { vi.clearAllMocks() @@ -1053,25 +834,20 @@ describe('Label Components Integration', () => { }) it('should render GlobalInputs label for shared field list', () => { - // Act render(<InputFieldPanel />) - // Assert expect(screen.getByTestId('global-inputs-label')).toBeInTheDocument() }) it('should render Datasource label for each datasource node', () => { - // Arrange const nodes = [ createDataSourceNode('node-1', 'First DataSource'), createDataSourceNode('node-2', 'Second DataSource'), ] setupMocks({ nodes }) - // Act render(<InputFieldPanel />) - // Assert expect( screen.getByTestId('datasource-label-First DataSource'), ).toBeInTheDocument() @@ -1081,10 +857,6 @@ describe('Label Components Integration', () => { }) }) -// ============================================================================ -// Component Memo Tests -// ============================================================================ - describe('Component Memo Behavior', () => { beforeEach(() => { vi.clearAllMocks() @@ -1092,14 +864,10 @@ describe('Component Memo Behavior', () => { }) it('should be wrapped with React.memo', () => { - // InputFieldPanel is exported as memo(InputFieldPanel) - // This test ensures the component doesn't break memoization const { rerender } = render(<InputFieldPanel />) - // Act - rerender without prop changes rerender(<InputFieldPanel />) - // Assert - component should still render correctly expect(screen.getByTestId('field-list-shared')).toBeInTheDocument() expect( screen.getByText('datasetPipeline.inputFieldPanel.title'), @@ -1107,15 +875,12 @@ describe('Component Memo Behavior', () => { }) it('should handle state updates correctly with memo', async () => { - // Arrange const nodes = [createDataSourceNode('node-1', 'DataSource 1')] setupMocks({ nodes }) render(<InputFieldPanel />) - // Act - trigger a state change fireEvent.click(screen.getByTestId('trigger-change-node-1')) - // Assert await waitFor(() => { expect(mockSetRagPipelineVariables).toHaveBeenCalled() }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx similarity index 82% rename from web/app/components/rag-pipeline/components/panel/input-field/editor/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx index 4e7f4f504d..d8feea44c6 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ -import type { FormData } from './form/types' -import type { InputFieldEditorProps } from './index' +import type { FormData } from '../form/types' +import type { InputFieldEditorProps } from '../index' import type { SupportUploadFileTypes } from '@/app/components/workflow/types' import type { InputVar } from '@/models/pipeline' import type { TransferMethod } from '@/types/app' @@ -7,28 +7,22 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { PipelineInputVarType } from '@/models/pipeline' -import InputFieldEditorPanel from './index' +import InputFieldEditorPanel from '../index' import { convertFormDataToINputField, convertToInputFieldFormData, -} from './utils' +} from '../utils' -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock useFloatingRight hook const mockUseFloatingRight = vi.fn(() => ({ floatingRight: false, floatingRightWidth: 400, })) -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useFloatingRight: () => mockUseFloatingRight(), })) -// Mock InputFieldForm component -vi.mock('./form', () => ({ +vi.mock('../form', () => ({ default: ({ initialData, supportFile, @@ -57,7 +51,6 @@ vi.mock('./form', () => ({ ), })) -// Mock file upload config service vi.mock('@/service/use-common', () => ({ useFileUploadConfig: () => ({ data: { @@ -72,10 +65,6 @@ vi.mock('@/service/use-common', () => ({ }), })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createInputVar = (overrides?: Partial<InputVar>): InputVar => ({ type: PipelineInputVarType.textInput, label: 'Test Label', @@ -120,10 +109,6 @@ const createInputFieldEditorProps = ( ...overrides, }) -// ============================================================================ -// Test Wrapper Component -// ============================================================================ - const createTestQueryClient = () => new QueryClient({ defaultOptions: { @@ -145,10 +130,6 @@ const renderWithProviders = (ui: React.ReactElement) => { return render(ui, { wrapper: TestWrapper }) } -// ============================================================================ -// InputFieldEditorPanel Component Tests -// ============================================================================ - describe('InputFieldEditorPanel', () => { beforeEach(() => { vi.clearAllMocks() @@ -158,103 +139,75 @@ describe('InputFieldEditorPanel', () => { }) }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render panel without crashing', () => { - // Arrange const props = createInputFieldEditorProps() - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect(screen.getByTestId('input-field-form')).toBeInTheDocument() }) it('should render close button', () => { - // Arrange const props = createInputFieldEditorProps() - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert const closeButton = screen.getByRole('button', { name: '' }) expect(closeButton).toBeInTheDocument() }) it('should render "Add Input Field" title when no initialData', () => { - // Arrange const props = createInputFieldEditorProps({ initialData: undefined }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.addInputField'), ).toBeInTheDocument() }) it('should render "Edit Input Field" title when initialData is provided', () => { - // Arrange const props = createInputFieldEditorProps({ initialData: createInputVar(), }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.editInputField'), ).toBeInTheDocument() }) it('should pass supportFile=true to form', () => { - // Arrange const props = createInputFieldEditorProps() - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect(screen.getByTestId('form-support-file').textContent).toBe('true') }) it('should pass isEditMode=false when no initialData', () => { - // Arrange const props = createInputFieldEditorProps({ initialData: undefined }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect(screen.getByTestId('form-is-edit-mode').textContent).toBe('false') }) it('should pass isEditMode=true when initialData is provided', () => { - // Arrange const props = createInputFieldEditorProps({ initialData: createInputVar(), }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect(screen.getByTestId('form-is-edit-mode').textContent).toBe('true') }) }) - // ------------------------------------------------------------------------- - // Props Variations Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should handle different input types in initialData', () => { - // Arrange const typesToTest = [ PipelineInputVarType.textInput, PipelineInputVarType.paragraph, @@ -269,19 +222,16 @@ describe('InputFieldEditorPanel', () => { const initialData = createInputVar({ type }) const props = createInputFieldEditorProps({ initialData }) - // Act const { unmount } = renderWithProviders( <InputFieldEditorPanel {...props} />, ) - // Assert expect(screen.getByTestId('input-field-form')).toBeInTheDocument() unmount() }) }) it('should handle initialData with all optional fields populated', () => { - // Arrange const initialData = createInputVar({ default_value: 'default', tooltips: 'tooltip text', @@ -294,15 +244,12 @@ describe('InputFieldEditorPanel', () => { }) const props = createInputFieldEditorProps({ initialData }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect(screen.getByTestId('input-field-form')).toBeInTheDocument() }) it('should handle initialData with minimal fields', () => { - // Arrange const initialData: InputVar = { type: PipelineInputVarType.textInput, label: 'Min', @@ -311,54 +258,40 @@ describe('InputFieldEditorPanel', () => { } const props = createInputFieldEditorProps({ initialData }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect(screen.getByTestId('input-field-form')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onClose when close button is clicked', () => { - // Arrange const onClose = vi.fn() const props = createInputFieldEditorProps({ onClose }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) fireEvent.click(screen.getByTestId('input-field-editor-close-btn')) - // Assert expect(onClose).toHaveBeenCalledTimes(1) }) it('should call onClose when form cancel is triggered', () => { - // Arrange const onClose = vi.fn() const props = createInputFieldEditorProps({ onClose }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) fireEvent.click(screen.getByTestId('form-cancel-btn')) - // Assert expect(onClose).toHaveBeenCalledTimes(1) }) it('should call onSubmit with converted data when form submits', () => { - // Arrange const onSubmit = vi.fn() const props = createInputFieldEditorProps({ onSubmit }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) fireEvent.click(screen.getByTestId('form-submit-btn')) - // Assert expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ @@ -370,35 +303,26 @@ describe('InputFieldEditorPanel', () => { }) }) - // ------------------------------------------------------------------------- - // Floating Right Behavior Tests - // ------------------------------------------------------------------------- describe('Floating Right Behavior', () => { it('should call useFloatingRight hook', () => { - // Arrange const props = createInputFieldEditorProps() - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect(mockUseFloatingRight).toHaveBeenCalled() }) it('should apply floating right styles when floatingRight is true', () => { - // Arrange mockUseFloatingRight.mockReturnValue({ floatingRight: true, floatingRightWidth: 300, }) const props = createInputFieldEditorProps() - // Act const { container } = renderWithProviders( <InputFieldEditorPanel {...props} />, ) - // Assert const panel = container.firstChild as HTMLElement expect(panel.className).toContain('absolute') expect(panel.className).toContain('right-0') @@ -406,35 +330,27 @@ describe('InputFieldEditorPanel', () => { }) it('should not apply floating right styles when floatingRight is false', () => { - // Arrange mockUseFloatingRight.mockReturnValue({ floatingRight: false, floatingRightWidth: 400, }) const props = createInputFieldEditorProps() - // Act const { container } = renderWithProviders( <InputFieldEditorPanel {...props} />, ) - // Assert const panel = container.firstChild as HTMLElement expect(panel.className).not.toContain('absolute') expect(panel.style.width).toBe('400px') }) }) - // ------------------------------------------------------------------------- - // Callback Stability and Memoization Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should maintain stable onClose callback reference', () => { - // Arrange const onClose = vi.fn() const props = createInputFieldEditorProps({ onClose }) - // Act const { rerender } = renderWithProviders( <InputFieldEditorPanel {...props} />, ) @@ -447,16 +363,13 @@ describe('InputFieldEditorPanel', () => { ) fireEvent.click(screen.getByTestId('form-cancel-btn')) - // Assert expect(onClose).toHaveBeenCalledTimes(2) }) it('should maintain stable onSubmit callback reference', () => { - // Arrange const onSubmit = vi.fn() const props = createInputFieldEditorProps({ onSubmit }) - // Act const { rerender } = renderWithProviders( <InputFieldEditorPanel {...props} />, ) @@ -469,21 +382,15 @@ describe('InputFieldEditorPanel', () => { ) fireEvent.click(screen.getByTestId('form-submit-btn')) - // Assert expect(onSubmit).toHaveBeenCalledTimes(2) }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should memoize formData when initialData does not change', () => { - // Arrange const initialData = createInputVar() const props = createInputFieldEditorProps({ initialData }) - // Act const { rerender } = renderWithProviders( <InputFieldEditorPanel {...props} />, ) @@ -496,18 +403,15 @@ describe('InputFieldEditorPanel', () => { ) const secondFormData = screen.getByTestId('form-initial-data').textContent - // Assert expect(firstFormData).toBe(secondFormData) }) it('should recompute formData when initialData changes', () => { - // Arrange const initialData1 = createInputVar({ variable: 'var1' }) const initialData2 = createInputVar({ variable: 'var2' }) const props1 = createInputFieldEditorProps({ initialData: initialData1 }) const props2 = createInputFieldEditorProps({ initialData: initialData2 }) - // Act const { rerender } = renderWithProviders( <InputFieldEditorPanel {...props1} />, ) @@ -520,33 +424,25 @@ describe('InputFieldEditorPanel', () => { ) const secondFormData = screen.getByTestId('form-initial-data').textContent - // Assert expect(firstFormData).not.toBe(secondFormData) expect(firstFormData).toContain('var1') expect(secondFormData).toContain('var2') }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle undefined initialData gracefully', () => { - // Arrange const props = createInputFieldEditorProps({ initialData: undefined }) - // Act & Assert expect(() => renderWithProviders(<InputFieldEditorPanel {...props} />), ).not.toThrow() }) it('should handle rapid close button clicks', () => { - // Arrange const onClose = vi.fn() const props = createInputFieldEditorProps({ onClose }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) const closeButtons = screen.getAllByRole('button') const closeButton = closeButtons.find(btn => btn.querySelector('svg')) @@ -557,12 +453,10 @@ describe('InputFieldEditorPanel', () => { fireEvent.click(closeButton) } - // Assert expect(onClose).toHaveBeenCalledTimes(3) }) it('should handle special characters in initialData', () => { - // Arrange const initialData = createInputVar({ label: 'Test <script>alert("xss")</script>', variable: 'test_var', @@ -570,15 +464,12 @@ describe('InputFieldEditorPanel', () => { }) const props = createInputFieldEditorProps({ initialData }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect(screen.getByTestId('input-field-form')).toBeInTheDocument() }) it('should handle empty string values in initialData', () => { - // Arrange const initialData = createInputVar({ label: '', variable: '', @@ -588,26 +479,16 @@ describe('InputFieldEditorPanel', () => { }) const props = createInputFieldEditorProps({ initialData }) - // Act renderWithProviders(<InputFieldEditorPanel {...props} />) - // Assert expect(screen.getByTestId('input-field-form')).toBeInTheDocument() }) }) }) -// ============================================================================ -// Utils Tests - convertToInputFieldFormData -// ============================================================================ - describe('convertToInputFieldFormData', () => { - // ------------------------------------------------------------------------- - // Basic Conversion Tests - // ------------------------------------------------------------------------- describe('Basic Conversion', () => { it('should convert InputVar to FormData with all fields', () => { - // Arrange const inputVar = createInputVar({ type: PipelineInputVarType.textInput, label: 'Test', @@ -621,10 +502,8 @@ describe('convertToInputFieldFormData', () => { unit: 'kg', }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.type).toBe(PipelineInputVarType.textInput) expect(result.label).toBe('Test') expect(result.variable).toBe('test_var') @@ -638,7 +517,6 @@ describe('convertToInputFieldFormData', () => { }) it('should convert file-related fields correctly', () => { - // Arrange const inputVar = createInputVar({ type: PipelineInputVarType.singleFile, allowed_file_upload_methods: ['local_file', 'remote_url'] as TransferMethod[], @@ -646,10 +524,8 @@ describe('convertToInputFieldFormData', () => { allowed_file_extensions: ['.jpg', '.pdf'], }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.allowedFileUploadMethods).toEqual([ 'local_file', 'remote_url', @@ -661,10 +537,8 @@ describe('convertToInputFieldFormData', () => { }) it('should return default template when data is undefined', () => { - // Act const result = convertToInputFieldFormData(undefined) - // Assert expect(result.type).toBe(PipelineInputVarType.textInput) expect(result.variable).toBe('') expect(result.label).toBe('') @@ -672,183 +546,140 @@ describe('convertToInputFieldFormData', () => { }) }) - // ------------------------------------------------------------------------- - // Optional Fields Handling Tests - // ------------------------------------------------------------------------- describe('Optional Fields Handling', () => { it('should not include default when default_value is undefined', () => { - // Arrange const inputVar = createInputVar({ default_value: undefined, }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.default).toBeUndefined() }) it('should not include default when default_value is null', () => { - // Arrange const inputVar: InputVar = { ...createInputVar(), default_value: null as unknown as string, } - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.default).toBeUndefined() }) it('should include default when default_value is empty string', () => { - // Arrange const inputVar = createInputVar({ default_value: '', }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.default).toBe('') }) it('should not include tooltips when undefined', () => { - // Arrange const inputVar = createInputVar({ tooltips: undefined, }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.tooltips).toBeUndefined() }) it('should not include placeholder when undefined', () => { - // Arrange const inputVar = createInputVar({ placeholder: undefined, }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.placeholder).toBeUndefined() }) it('should not include unit when undefined', () => { - // Arrange const inputVar = createInputVar({ unit: undefined, }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.unit).toBeUndefined() }) it('should not include file settings when allowed_file_upload_methods is undefined', () => { - // Arrange const inputVar = createInputVar({ allowed_file_upload_methods: undefined, }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.allowedFileUploadMethods).toBeUndefined() }) it('should not include allowedTypesAndExtensions details when file types/extensions are missing', () => { - // Arrange const inputVar = createInputVar({ allowed_file_types: undefined, allowed_file_extensions: undefined, }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.allowedTypesAndExtensions).toEqual({}) }) }) - // ------------------------------------------------------------------------- - // Type-Specific Tests - // ------------------------------------------------------------------------- describe('Type-Specific Handling', () => { it('should handle textInput type', () => { - // Arrange const inputVar = createInputVar({ type: PipelineInputVarType.textInput, max_length: 256, }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.type).toBe(PipelineInputVarType.textInput) expect(result.maxLength).toBe(256) }) it('should handle paragraph type', () => { - // Arrange const inputVar = createInputVar({ type: PipelineInputVarType.paragraph, max_length: 1000, }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.type).toBe(PipelineInputVarType.paragraph) expect(result.maxLength).toBe(1000) }) it('should handle number type with unit', () => { - // Arrange const inputVar = createInputVar({ type: PipelineInputVarType.number, unit: 'meters', }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.type).toBe(PipelineInputVarType.number) expect(result.unit).toBe('meters') }) it('should handle select type with options', () => { - // Arrange const inputVar = createInputVar({ type: PipelineInputVarType.select, options: ['Option A', 'Option B', 'Option C'], }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.type).toBe(PipelineInputVarType.select) expect(result.options).toEqual(['Option A', 'Option B', 'Option C']) }) it('should handle singleFile type', () => { - // Arrange const inputVar = createInputVar({ type: PipelineInputVarType.singleFile, allowed_file_upload_methods: ['local_file'] as TransferMethod[], @@ -856,16 +687,13 @@ describe('convertToInputFieldFormData', () => { allowed_file_extensions: ['.jpg'], }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.type).toBe(PipelineInputVarType.singleFile) expect(result.allowedFileUploadMethods).toEqual(['local_file']) }) it('should handle multiFiles type', () => { - // Arrange const inputVar = createInputVar({ type: PipelineInputVarType.multiFiles, max_length: 5, @@ -874,42 +702,29 @@ describe('convertToInputFieldFormData', () => { allowed_file_extensions: ['.pdf', '.doc'], }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.type).toBe(PipelineInputVarType.multiFiles) expect(result.maxLength).toBe(5) }) it('should handle checkbox type', () => { - // Arrange const inputVar = createInputVar({ type: PipelineInputVarType.checkbox, default_value: 'true', }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.type).toBe(PipelineInputVarType.checkbox) expect(result.default).toBe('true') }) }) }) -// ============================================================================ -// Utils Tests - convertFormDataToINputField -// ============================================================================ - describe('convertFormDataToINputField', () => { - // ------------------------------------------------------------------------- - // Basic Conversion Tests - // ------------------------------------------------------------------------- describe('Basic Conversion', () => { it('should convert FormData to InputVar with all fields', () => { - // Arrange const formData = createFormData({ type: PipelineInputVarType.textInput, label: 'Test', @@ -928,10 +743,8 @@ describe('convertFormDataToINputField', () => { }, }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(PipelineInputVarType.textInput) expect(result.label).toBe('Test') expect(result.variable).toBe('test_var') @@ -948,7 +761,6 @@ describe('convertFormDataToINputField', () => { }) it('should handle undefined optional fields', () => { - // Arrange const formData = createFormData({ default: undefined, tooltips: undefined, @@ -961,10 +773,8 @@ describe('convertFormDataToINputField', () => { }, }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.default_value).toBeUndefined() expect(result.tooltips).toBeUndefined() expect(result.placeholder).toBeUndefined() @@ -975,42 +785,30 @@ describe('convertFormDataToINputField', () => { }) }) - // ------------------------------------------------------------------------- - // Field Mapping Tests - // ------------------------------------------------------------------------- describe('Field Mapping', () => { it('should map maxLength to max_length', () => { - // Arrange const formData = createFormData({ maxLength: 256 }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.max_length).toBe(256) }) it('should map default to default_value', () => { - // Arrange const formData = createFormData({ default: 'my default' }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.default_value).toBe('my default') }) it('should map allowedFileUploadMethods to allowed_file_upload_methods', () => { - // Arrange const formData = createFormData({ allowedFileUploadMethods: ['local_file', 'remote_url'] as TransferMethod[], }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.allowed_file_upload_methods).toEqual([ 'local_file', 'remote_url', @@ -1018,7 +816,6 @@ describe('convertFormDataToINputField', () => { }) it('should map allowedTypesAndExtensions to separate fields', () => { - // Arrange const formData = createFormData({ allowedTypesAndExtensions: { allowedFileTypes: ['image', 'document'] as SupportUploadFileTypes[], @@ -1026,119 +823,88 @@ describe('convertFormDataToINputField', () => { }, }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.allowed_file_types).toEqual(['image', 'document']) expect(result.allowed_file_extensions).toEqual(['.jpg', '.pdf']) }) }) - // ------------------------------------------------------------------------- - // Type-Specific Tests - // ------------------------------------------------------------------------- describe('Type-Specific Handling', () => { it('should preserve textInput type', () => { - // Arrange const formData = createFormData({ type: PipelineInputVarType.textInput }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(PipelineInputVarType.textInput) }) it('should preserve paragraph type', () => { - // Arrange const formData = createFormData({ type: PipelineInputVarType.paragraph }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(PipelineInputVarType.paragraph) }) it('should preserve select type with options', () => { - // Arrange const formData = createFormData({ type: PipelineInputVarType.select, options: ['A', 'B', 'C'], }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(PipelineInputVarType.select) expect(result.options).toEqual(['A', 'B', 'C']) }) it('should preserve number type with unit', () => { - // Arrange const formData = createFormData({ type: PipelineInputVarType.number, unit: 'kg', }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(PipelineInputVarType.number) expect(result.unit).toBe('kg') }) it('should preserve singleFile type', () => { - // Arrange const formData = createFormData({ type: PipelineInputVarType.singleFile, }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(PipelineInputVarType.singleFile) }) it('should preserve multiFiles type with maxLength', () => { - // Arrange const formData = createFormData({ type: PipelineInputVarType.multiFiles, maxLength: 10, }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(PipelineInputVarType.multiFiles) expect(result.max_length).toBe(10) }) it('should preserve checkbox type', () => { - // Arrange const formData = createFormData({ type: PipelineInputVarType.checkbox }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(PipelineInputVarType.checkbox) }) }) }) -// ============================================================================ -// Round-Trip Conversion Tests -// ============================================================================ - describe('Round-Trip Conversion', () => { it('should preserve data through round-trip conversion for textInput', () => { - // Arrange const original = createInputVar({ type: PipelineInputVarType.textInput, label: 'Test Label', @@ -1150,11 +916,9 @@ describe('Round-Trip Conversion', () => { placeholder: 'placeholder', }) - // Act const formData = convertToInputFieldFormData(original) const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(original.type) expect(result.label).toBe(original.label) expect(result.variable).toBe(original.variable) @@ -1166,25 +930,21 @@ describe('Round-Trip Conversion', () => { }) it('should preserve data through round-trip conversion for select', () => { - // Arrange const original = createInputVar({ type: PipelineInputVarType.select, options: ['Option A', 'Option B', 'Option C'], default_value: 'Option A', }) - // Act const formData = convertToInputFieldFormData(original) const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(original.type) expect(result.options).toEqual(original.options) expect(result.default_value).toBe(original.default_value) }) it('should preserve data through round-trip conversion for file types', () => { - // Arrange const original = createInputVar({ type: PipelineInputVarType.multiFiles, max_length: 5, @@ -1193,11 +953,9 @@ describe('Round-Trip Conversion', () => { allowed_file_extensions: ['.jpg', '.pdf'], }) - // Act const formData = convertToInputFieldFormData(original) const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(original.type) expect(result.max_length).toBe(original.max_length) expect(result.allowed_file_upload_methods).toEqual( @@ -1210,7 +968,6 @@ describe('Round-Trip Conversion', () => { }) it('should handle all input types through round-trip', () => { - // Arrange const typesToTest = [ PipelineInputVarType.textInput, PipelineInputVarType.paragraph, @@ -1224,54 +981,39 @@ describe('Round-Trip Conversion', () => { typesToTest.forEach((type) => { const original = createInputVar({ type }) - // Act const formData = convertToInputFieldFormData(original) const result = convertFormDataToINputField(formData) - // Assert expect(result.type).toBe(original.type) }) }) }) -// ============================================================================ -// Edge Cases Tests -// ============================================================================ - describe('Edge Cases', () => { describe('convertToInputFieldFormData edge cases', () => { it('should handle zero maxLength', () => { - // Arrange const inputVar = createInputVar({ max_length: 0 }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.maxLength).toBe(0) }) it('should handle empty options array', () => { - // Arrange const inputVar = createInputVar({ options: [] }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.options).toEqual([]) }) it('should handle options with special characters', () => { - // Arrange const inputVar = createInputVar({ options: ['<script>', '"quoted"', '\'apostrophe\'', '&'], }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.options).toEqual([ '<script>', '"quoted"', @@ -1281,33 +1023,27 @@ describe('Edge Cases', () => { }) it('should handle very long strings', () => { - // Arrange const longString = 'a'.repeat(10000) const inputVar = createInputVar({ label: longString, tooltips: longString, }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.label).toBe(longString) expect(result.tooltips).toBe(longString) }) it('should handle unicode characters', () => { - // Arrange const inputVar = createInputVar({ label: 'æ”‹èŻ•æ ‡ç­Ÿ 🎉', tooltips: 'ăƒ„ăƒŒăƒ«ăƒăƒƒăƒ— 😀', placeholder: 'Platzhalter ñ Ă©', }) - // Act const result = convertToInputFieldFormData(inputVar) - // Assert expect(result.label).toBe('æ”‹èŻ•æ ‡ç­Ÿ 🎉') expect(result.tooltips).toBe('ăƒ„ăƒŒăƒ«ăƒăƒƒăƒ— 😀') expect(result.placeholder).toBe('Platzhalter ñ Ă©') @@ -1316,18 +1052,14 @@ describe('Edge Cases', () => { describe('convertFormDataToINputField edge cases', () => { it('should handle zero maxLength', () => { - // Arrange const formData = createFormData({ maxLength: 0 }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.max_length).toBe(0) }) it('should handle empty allowedTypesAndExtensions', () => { - // Arrange const formData = createFormData({ allowedTypesAndExtensions: { allowedFileTypes: [], @@ -1335,51 +1067,38 @@ describe('Edge Cases', () => { }, }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.allowed_file_types).toEqual([]) expect(result.allowed_file_extensions).toEqual([]) }) it('should handle boolean default value (checkbox)', () => { - // Arrange const formData = createFormData({ type: PipelineInputVarType.checkbox, default: 'true', }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.default_value).toBe('true') }) it('should handle numeric default value (number type)', () => { - // Arrange const formData = createFormData({ type: PipelineInputVarType.number, default: '42', }) - // Act const result = convertFormDataToINputField(formData) - // Assert expect(result.default_value).toBe('42') }) }) }) -// ============================================================================ -// Hook Memoization Tests -// ============================================================================ - describe('Hook Memoization', () => { it('should return stable callback reference for handleSubmit', () => { - // Arrange const onSubmit = vi.fn() let handleSubmitRef1: ((value: FormData) => void) | undefined let handleSubmitRef2: ((value: FormData) => void) | undefined @@ -1402,7 +1121,6 @@ describe('Hook Memoization', () => { return null } - // Act const { rerender } = render( <TestComponent capture={(ref) => { handleSubmitRef1 = ref }} submitFn={onSubmit} />, ) @@ -1410,12 +1128,10 @@ describe('Hook Memoization', () => { <TestComponent capture={(ref) => { handleSubmitRef2 = ref }} submitFn={onSubmit} />, ) - // Assert - callback should be same reference due to useCallback expect(handleSubmitRef1).toBe(handleSubmitRef2) }) it('should return stable formData when initialData is unchanged', () => { - // Arrange const initialData = createInputVar() let formData1: FormData | undefined let formData2: FormData | undefined @@ -1435,7 +1151,6 @@ describe('Hook Memoization', () => { return null } - // Act const { rerender } = render( <TestComponent data={initialData} @@ -1449,7 +1164,6 @@ describe('Hook Memoization', () => { />, ) - // Assert - formData should be same reference due to useMemo expect(formData1).toBe(formData2) }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..d07a705252 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts @@ -0,0 +1,366 @@ +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PipelineInputVarType } from '@/models/pipeline' +import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from '../hooks' + +vi.mock('@/app/components/base/file-uploader/hooks', () => ({ + useFileSizeLimit: () => ({ + imgSizeLimit: 10 * 1024 * 1024, + docSizeLimit: 15 * 1024 * 1024, + audioSizeLimit: 50 * 1024 * 1024, + videoSizeLimit: 100 * 1024 * 1024, + }), +})) + +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ data: {} }), +})) + +vi.mock('@/app/components/workflow/constants', () => ({ + DEFAULT_FILE_UPLOAD_SETTING: { + allowed_file_upload_methods: ['local_file', 'remote_url'], + allowed_file_types: ['image', 'document'], + allowed_file_extensions: ['.jpg', '.png', '.pdf'], + max_length: 5, + }, +})) + +vi.mock('../schema', () => ({ + TEXT_MAX_LENGTH: 256, +})) + +vi.mock('@/utils/format', () => ({ + formatFileSize: (size: number) => `${Math.round(size / 1024 / 1024)}MB`, +})) + +describe('useHiddenFieldNames', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('should return field names for textInput type', () => { + const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.textInput)) + + expect(result.current).toContain('variableconfig.defaultvalue') + expect(result.current).toContain('variableconfig.placeholder') + expect(result.current).toContain('variableconfig.tooltips') + }) + + it('should return field names for paragraph type', () => { + const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.paragraph)) + + expect(result.current).toContain('variableconfig.defaultvalue') + expect(result.current).toContain('variableconfig.placeholder') + expect(result.current).toContain('variableconfig.tooltips') + }) + + it('should return field names for number type including unit', () => { + const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.number)) + + expect(result.current).toContain('appdebug.variableconfig.defaultvalue') + expect(result.current).toContain('appdebug.variableconfig.unit') + expect(result.current).toContain('appdebug.variableconfig.placeholder') + expect(result.current).toContain('appdebug.variableconfig.tooltips') + }) + + it('should return field names for select type', () => { + const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.select)) + + expect(result.current).toContain('appdebug.variableconfig.defaultvalue') + expect(result.current).toContain('appdebug.variableconfig.tooltips') + }) + + it('should return field names for singleFile type', () => { + const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.singleFile)) + + expect(result.current).toContain('appdebug.variableconfig.uploadmethod') + expect(result.current).toContain('appdebug.variableconfig.tooltips') + }) + + it('should return field names for multiFiles type including max number', () => { + const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.multiFiles)) + + expect(result.current).toContain('appdebug.variableconfig.uploadmethod') + expect(result.current).toContain('appdebug.variableconfig.maxnumberofuploads') + expect(result.current).toContain('appdebug.variableconfig.tooltips') + }) + + it('should return field names for checkbox type', () => { + const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.checkbox)) + + expect(result.current).toContain('appdebug.variableconfig.startchecked') + expect(result.current).toContain('appdebug.variableconfig.tooltips') + }) + + it('should return only tooltips for unknown type', () => { + const { result } = renderHook(() => useHiddenFieldNames('unknown-type' as PipelineInputVarType)) + + expect(result.current).toBe('appdebug.variableconfig.tooltips') + }) + + it('should return comma-separated lowercase string', () => { + const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.textInput)) + + expect(result.current).toMatch(/,/) + expect(result.current).toBe(result.current.toLowerCase()) + }) +}) + +describe('useConfigurations', () => { + let mockGetFieldValue: ReturnType<typeof vi.fn<(...args: unknown[]) => unknown>> + let mockSetFieldValue: ReturnType<typeof vi.fn<(...args: unknown[]) => void>> + + beforeEach(() => { + mockGetFieldValue = vi.fn() + mockSetFieldValue = vi.fn() + vi.clearAllMocks() + }) + + it('should return array of configurations', () => { + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + expect(Array.isArray(result.current)).toBe(true) + expect(result.current.length).toBeGreaterThan(0) + }) + + it('should include field type select configuration', () => { + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + const typeConfig = result.current.find(c => c.variable === 'type') + expect(typeConfig).toBeDefined() + expect(typeConfig?.required).toBe(true) + }) + + it('should include variable name configuration', () => { + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + const varConfig = result.current.find(c => c.variable === 'variable') + expect(varConfig).toBeDefined() + expect(varConfig?.required).toBe(true) + }) + + it('should include display name configuration', () => { + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + const labelConfig = result.current.find(c => c.variable === 'label') + expect(labelConfig).toBeDefined() + expect(labelConfig?.required).toBe(false) + }) + + it('should include required checkbox configuration', () => { + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + const requiredConfig = result.current.find(c => c.variable === 'required') + expect(requiredConfig).toBeDefined() + }) + + it('should set file defaults when type changes to singleFile', () => { + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + const typeConfig = result.current.find(c => c.variable === 'type') + typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.singleFile, fieldApi: {} as never }) + + expect(mockSetFieldValue).toHaveBeenCalledWith('allowedFileUploadMethods', ['local_file', 'remote_url']) + expect(mockSetFieldValue).toHaveBeenCalledWith('allowedTypesAndExtensions', { + allowedFileTypes: ['image', 'document'], + allowedFileExtensions: ['.jpg', '.png', '.pdf'], + }) + }) + + it('should set maxLength when type changes to multiFiles', () => { + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + const typeConfig = result.current.find(c => c.variable === 'type') + typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.multiFiles, fieldApi: {} as never }) + + expect(mockSetFieldValue).toHaveBeenCalledWith('maxLength', 5) + }) + + it('should not set file defaults when type changes to text', () => { + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + const typeConfig = result.current.find(c => c.variable === 'type') + typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.textInput, fieldApi: {} as never }) + + expect(mockSetFieldValue).not.toHaveBeenCalled() + }) + + it('should auto-fill label from variable name on blur', () => { + mockGetFieldValue.mockReturnValue('') + + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + const varConfig = result.current.find(c => c.variable === 'variable') + varConfig?.listeners?.onBlur?.({ value: 'myVariable', fieldApi: {} as never }) + + expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'myVariable') + }) + + it('should not auto-fill label if label already exists', () => { + mockGetFieldValue.mockReturnValue('Existing Label') + + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + const varConfig = result.current.find(c => c.variable === 'variable') + varConfig?.listeners?.onBlur?.({ value: 'myVariable', fieldApi: {} as never }) + + expect(mockSetFieldValue).not.toHaveBeenCalled() + }) + + it('should reset label to variable name when display name is cleared', () => { + mockGetFieldValue.mockReturnValue('existingVar') + + const { result } = renderHook(() => + useConfigurations({ + getFieldValue: mockGetFieldValue, + setFieldValue: mockSetFieldValue, + supportFile: true, + }), + ) + + const labelConfig = result.current.find(c => c.variable === 'label') + labelConfig?.listeners?.onBlur?.({ value: '', fieldApi: {} as never }) + + expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'existingVar') + }) +}) + +describe('useHiddenConfigurations', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('should return array of hidden configurations', () => { + const { result } = renderHook(() => + useHiddenConfigurations({ options: undefined }), + ) + + expect(Array.isArray(result.current)).toBe(true) + expect(result.current.length).toBeGreaterThan(0) + }) + + it('should include default value config for textInput', () => { + const { result } = renderHook(() => + useHiddenConfigurations({ options: undefined }), + ) + + const defaultConfigs = result.current.filter(c => c.variable === 'default') + expect(defaultConfigs.length).toBeGreaterThan(0) + }) + + it('should include tooltips configuration for all types', () => { + const { result } = renderHook(() => + useHiddenConfigurations({ options: undefined }), + ) + + const tooltipsConfig = result.current.find(c => c.variable === 'tooltips') + expect(tooltipsConfig).toBeDefined() + expect(tooltipsConfig?.showConditions).toEqual([]) + }) + + it('should build select options from provided options', () => { + const { result } = renderHook(() => + useHiddenConfigurations({ options: ['opt1', 'opt2'] }), + ) + + const selectDefault = result.current.find( + c => c.variable === 'default' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.select), + ) + expect(selectDefault?.options).toBeDefined() + expect(selectDefault?.options?.[0]?.value).toBe('') + expect(selectDefault?.options?.[1]?.value).toBe('opt1') + expect(selectDefault?.options?.[2]?.value).toBe('opt2') + }) + + it('should return empty options when options prop is undefined', () => { + const { result } = renderHook(() => + useHiddenConfigurations({ options: undefined }), + ) + + const selectDefault = result.current.find( + c => c.variable === 'default' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.select), + ) + expect(selectDefault?.options).toEqual([]) + }) + + it('should include upload method configs for file types', () => { + const { result } = renderHook(() => + useHiddenConfigurations({ options: undefined }), + ) + + const uploadMethods = result.current.filter(c => c.variable === 'allowedFileUploadMethods') + expect(uploadMethods.length).toBe(2) // singleFile + multiFiles + }) + + it('should include maxLength slider for multiFiles', () => { + const { result } = renderHook(() => + useHiddenConfigurations({ options: undefined }), + ) + + const maxLength = result.current.find( + c => c.variable === 'maxLength' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.multiFiles), + ) + expect(maxLength).toBeDefined() + expect(maxLength?.description).toBeDefined() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx similarity index 77% rename from web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx index 48df13acb2..adc249a88d 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx @@ -1,21 +1,14 @@ -import type { FormData, InputFieldFormProps } from './types' +import type { FormData, InputFieldFormProps } from '../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { PipelineInputVarType } from '@/models/pipeline' -import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from './hooks' -import InputFieldForm from './index' -import { createInputFieldSchema, TEXT_MAX_LENGTH } from './schema' +import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from '../hooks' +import InputFieldForm from '../index' +import { createInputFieldSchema, TEXT_MAX_LENGTH } from '../schema' -// Type helper for partial listener event parameters in tests -// Using double assertion for test mocks with incomplete event objects const createMockEvent = <T,>(value: T) => ({ value }) as unknown as Parameters<NonNullable<NonNullable<ReturnType<typeof useConfigurations>[number]['listeners']>['onChange']>>[0] -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock file upload config service const mockFileUploadConfig = { image_file_size_limit: 10, file_size_limit: 15, @@ -32,17 +25,12 @@ vi.mock('@/service/use-common', () => ({ }), })) -// Mock Toast static method vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), }, })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createFormData = (overrides?: Partial<FormData>): FormData => ({ type: PipelineInputVarType.textInput, label: 'Test Label', @@ -71,10 +59,6 @@ const createInputFieldFormProps = (overrides?: Partial<InputFieldFormProps>): In ...overrides, }) -// ============================================================================ -// Test Wrapper Component -// ============================================================================ - const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { @@ -101,107 +85,75 @@ const renderHookWithProviders = <TResult,>(hook: () => TResult) => { return renderHook(hook, { wrapper: TestWrapper }) } -// ============================================================================ -// InputFieldForm Component Tests -// ============================================================================ - describe('InputFieldForm', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render form without crashing', () => { - // Arrange const props = createInputFieldFormProps() - // Act const { container } = renderWithProviders(<InputFieldForm {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) it('should render cancel button', () => { - // Arrange const props = createInputFieldFormProps() - // Act renderWithProviders(<InputFieldForm {...props} />) - // Assert expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() }) it('should render form with initial values', () => { - // Arrange const initialData = createFormData({ variable: 'custom_var', label: 'Custom Label', }) const props = createInputFieldFormProps({ initialData }) - // Act const { container } = renderWithProviders(<InputFieldForm {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Props Variations Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should handle supportFile=true prop', () => { - // Arrange const props = createInputFieldFormProps({ supportFile: true }) - // Act const { container } = renderWithProviders(<InputFieldForm {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) it('should handle supportFile=false (default) prop', () => { - // Arrange const props = createInputFieldFormProps({ supportFile: false }) - // Act const { container } = renderWithProviders(<InputFieldForm {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) it('should handle isEditMode=true prop', () => { - // Arrange const props = createInputFieldFormProps({ isEditMode: true }) - // Act const { container } = renderWithProviders(<InputFieldForm {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) it('should handle isEditMode=false prop', () => { - // Arrange const props = createInputFieldFormProps({ isEditMode: false }) - // Act const { container } = renderWithProviders(<InputFieldForm {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) it('should handle different initial data types', () => { - // Arrange const typesToTest = [ PipelineInputVarType.textInput, PipelineInputVarType.paragraph, @@ -214,49 +166,37 @@ describe('InputFieldForm', () => { const initialData = createFormData({ type }) const props = createInputFieldFormProps({ initialData }) - // Act const { container, unmount } = renderWithProviders(<InputFieldForm {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() unmount() }) }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onCancel when cancel button is clicked', async () => { - // Arrange const onCancel = vi.fn() const props = createInputFieldFormProps({ onCancel }) - // Act renderWithProviders(<InputFieldForm {...props} />) fireEvent.click(screen.getByRole('button', { name: /cancel/i })) - // Assert expect(onCancel).toHaveBeenCalledTimes(1) }) it('should prevent default on form submit', async () => { - // Arrange const props = createInputFieldFormProps() const { container } = renderWithProviders(<InputFieldForm {...props} />) const form = container.querySelector('form')! const submitEvent = new Event('submit', { bubbles: true, cancelable: true }) - // Act form.dispatchEvent(submitEvent) - // Assert expect(submitEvent.defaultPrevented).toBe(true) }) it('should show Toast error when form validation fails on submit', async () => { - // Arrange - Create invalid form data with empty variable name (validation should fail) const Toast = await import('@/app/components/base/toast') const initialData = createFormData({ variable: '', // Empty variable should fail validation @@ -265,12 +205,10 @@ describe('InputFieldForm', () => { const onSubmit = vi.fn() const props = createInputFieldFormProps({ initialData, onSubmit }) - // Act const { container } = renderWithProviders(<InputFieldForm {...props} />) const form = container.querySelector('form')! fireEvent.submit(form) - // Assert - Toast should be called with error message when validation fails await waitFor(() => { expect(Toast.default.notify).toHaveBeenCalledWith( expect.objectContaining({ @@ -279,12 +217,10 @@ describe('InputFieldForm', () => { }), ) }) - // onSubmit should not be called when validation fails expect(onSubmit).not.toHaveBeenCalled() }) it('should call onSubmit with moreInfo when variable name changes in edit mode', async () => { - // Arrange - Initial variable name is 'original_var', we change it to 'new_var' const initialData = createFormData({ variable: 'original_var', label: 'Test Label', @@ -296,18 +232,14 @@ describe('InputFieldForm', () => { isEditMode: true, }) - // Act renderWithProviders(<InputFieldForm {...props} />) - // Find and change the variable input by label const variableInput = screen.getByLabelText('appDebug.variableConfig.varName') fireEvent.change(variableInput, { target: { value: 'new_var' } }) - // Submit the form const form = document.querySelector('form')! fireEvent.submit(form) - // Assert - onSubmit should be called with moreInfo containing variable name change info await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ @@ -325,7 +257,6 @@ describe('InputFieldForm', () => { }) it('should call onSubmit without moreInfo when variable name does not change in edit mode', async () => { - // Arrange - Variable name stays the same const initialData = createFormData({ variable: 'same_var', label: 'Test Label', @@ -337,14 +268,11 @@ describe('InputFieldForm', () => { isEditMode: true, }) - // Act renderWithProviders(<InputFieldForm {...props} />) - // Submit without changing variable name const form = document.querySelector('form')! fireEvent.submit(form) - // Assert - onSubmit should be called without moreInfo (undefined) await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ @@ -356,7 +284,6 @@ describe('InputFieldForm', () => { }) it('should call onSubmit without moreInfo when not in edit mode', async () => { - // Arrange const initialData = createFormData({ variable: 'test_var', label: 'Test Label', @@ -368,14 +295,11 @@ describe('InputFieldForm', () => { isEditMode: false, }) - // Act renderWithProviders(<InputFieldForm {...props} />) - // Submit the form const form = document.querySelector('form')! fireEvent.submit(form) - // Assert - onSubmit should be called without moreInfo since not in edit mode await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.any(Object), @@ -385,76 +309,55 @@ describe('InputFieldForm', () => { }) }) - // ------------------------------------------------------------------------- - // State Management Tests - // ------------------------------------------------------------------------- describe('State Management', () => { it('should initialize showAllSettings state as false', () => { - // Arrange const props = createInputFieldFormProps() - // Act renderWithProviders(<InputFieldForm {...props} />) - // Assert - ShowAllSettings component should be visible when showAllSettings is false expect(screen.queryByText(/appDebug.variableConfig.showAllSettings/i)).toBeInTheDocument() }) it('should toggle showAllSettings state when clicking show all settings', async () => { - // Arrange const props = createInputFieldFormProps() renderWithProviders(<InputFieldForm {...props} />) - // Act - Find and click the show all settings element const showAllSettingsElement = screen.getByText(/appDebug.variableConfig.showAllSettings/i) const clickableParent = showAllSettingsElement.closest('.cursor-pointer') if (clickableParent) { fireEvent.click(clickableParent) } - // Assert - After clicking, ShowAllSettings should be hidden and HiddenFields should be visible await waitFor(() => { expect(screen.queryByText(/appDebug.variableConfig.showAllSettings/i)).not.toBeInTheDocument() }) }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should maintain stable onCancel callback reference', () => { - // Arrange const onCancel = vi.fn() const props = createInputFieldFormProps({ onCancel }) - // Act renderWithProviders(<InputFieldForm {...props} />) const cancelButton = screen.getByRole('button', { name: /cancel/i }) fireEvent.click(cancelButton) fireEvent.click(cancelButton) - // Assert expect(onCancel).toHaveBeenCalledTimes(2) }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty initial data gracefully', () => { - // Arrange const props = createInputFieldFormProps({ initialData: {} as Record<string, unknown>, }) - // Act & Assert - should not crash expect(() => renderWithProviders(<InputFieldForm {...props} />)).not.toThrow() }) it('should handle undefined optional fields', () => { - // Arrange const initialData = { type: PipelineInputVarType.textInput, label: 'Test', @@ -464,75 +367,57 @@ describe('InputFieldForm', () => { allowedFileTypes: [], allowedFileExtensions: [], }, - // Other fields are undefined } const props = createInputFieldFormProps({ initialData }) - // Act & Assert expect(() => renderWithProviders(<InputFieldForm {...props} />)).not.toThrow() }) it('should handle special characters in variable name', () => { - // Arrange const initialData = createFormData({ variable: 'test_var_123', label: 'Test Label <script>', }) const props = createInputFieldFormProps({ initialData }) - // Act const { container } = renderWithProviders(<InputFieldForm {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) }) }) -// ============================================================================ -// useHiddenFieldNames Hook Tests -// ============================================================================ - describe('useHiddenFieldNames', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Return Value Tests for Different Types - // ------------------------------------------------------------------------- describe('Return Values by Type', () => { it('should return correct field names for textInput type', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenFieldNames(PipelineInputVarType.textInput), ) - // Assert - should include default value, placeholder, tooltips expect(result.current).toContain('appDebug.variableConfig.defaultValue'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.placeholder'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.tooltips'.toLowerCase()) }) it('should return correct field names for paragraph type', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenFieldNames(PipelineInputVarType.paragraph), ) - // Assert expect(result.current).toContain('appDebug.variableConfig.defaultValue'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.placeholder'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.tooltips'.toLowerCase()) }) it('should return correct field names for number type', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenFieldNames(PipelineInputVarType.number), ) - // Assert - should include unit field expect(result.current).toContain('appDebug.variableConfig.defaultValue'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.unit'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.placeholder'.toLowerCase()) @@ -540,86 +425,64 @@ describe('useHiddenFieldNames', () => { }) it('should return correct field names for select type', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenFieldNames(PipelineInputVarType.select), ) - // Assert expect(result.current).toContain('appDebug.variableConfig.defaultValue'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.tooltips'.toLowerCase()) }) it('should return correct field names for singleFile type', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenFieldNames(PipelineInputVarType.singleFile), ) - // Assert expect(result.current).toContain('appDebug.variableConfig.uploadMethod'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.tooltips'.toLowerCase()) }) it('should return correct field names for multiFiles type', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenFieldNames(PipelineInputVarType.multiFiles), ) - // Assert expect(result.current).toContain('appDebug.variableConfig.uploadMethod'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.maxNumberOfUploads'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.tooltips'.toLowerCase()) }) it('should return correct field names for checkbox type', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenFieldNames(PipelineInputVarType.checkbox), ) - // Assert expect(result.current).toContain('appDebug.variableConfig.startChecked'.toLowerCase()) expect(result.current).toContain('appDebug.variableConfig.tooltips'.toLowerCase()) }) }) - // ------------------------------------------------------------------------- - // Edge Cases - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should return tooltips only for unknown type', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenFieldNames('unknown_type' as PipelineInputVarType), ) - // Assert - should only contain tooltips for unknown types expect(result.current).toContain('appDebug.variableConfig.tooltips'.toLowerCase()) }) }) }) -// ============================================================================ -// useConfigurations Hook Tests -// ============================================================================ - describe('useConfigurations', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Configuration Generation Tests - // ------------------------------------------------------------------------- describe('Configuration Generation', () => { it('should return array of configurations', () => { - // Arrange const mockGetFieldValue = vi.fn() const mockSetFieldValue = vi.fn() - // Act const { result } = renderHookWithProviders(() => useConfigurations({ getFieldValue: mockGetFieldValue, @@ -628,17 +491,14 @@ describe('useConfigurations', () => { }), ) - // Assert expect(Array.isArray(result.current)).toBe(true) expect(result.current.length).toBeGreaterThan(0) }) it('should include type field configuration', () => { - // Arrange const mockGetFieldValue = vi.fn() const mockSetFieldValue = vi.fn() - // Act const { result } = renderHookWithProviders(() => useConfigurations({ getFieldValue: mockGetFieldValue, @@ -647,18 +507,15 @@ describe('useConfigurations', () => { }), ) - // Assert const typeConfig = result.current.find(config => config.variable === 'type') expect(typeConfig).toBeDefined() expect(typeConfig?.required).toBe(true) }) it('should include variable field configuration', () => { - // Arrange const mockGetFieldValue = vi.fn() const mockSetFieldValue = vi.fn() - // Act const { result } = renderHookWithProviders(() => useConfigurations({ getFieldValue: mockGetFieldValue, @@ -667,18 +524,15 @@ describe('useConfigurations', () => { }), ) - // Assert const variableConfig = result.current.find(config => config.variable === 'variable') expect(variableConfig).toBeDefined() expect(variableConfig?.required).toBe(true) }) it('should include label field configuration', () => { - // Arrange const mockGetFieldValue = vi.fn() const mockSetFieldValue = vi.fn() - // Act const { result } = renderHookWithProviders(() => useConfigurations({ getFieldValue: mockGetFieldValue, @@ -687,18 +541,15 @@ describe('useConfigurations', () => { }), ) - // Assert const labelConfig = result.current.find(config => config.variable === 'label') expect(labelConfig).toBeDefined() expect(labelConfig?.required).toBe(false) }) it('should include required field configuration', () => { - // Arrange const mockGetFieldValue = vi.fn() const mockSetFieldValue = vi.fn() - // Act const { result } = renderHookWithProviders(() => useConfigurations({ getFieldValue: mockGetFieldValue, @@ -707,17 +558,14 @@ describe('useConfigurations', () => { }), ) - // Assert const requiredConfig = result.current.find(config => config.variable === 'required') expect(requiredConfig).toBeDefined() }) it('should pass supportFile prop to type configuration', () => { - // Arrange const mockGetFieldValue = vi.fn() const mockSetFieldValue = vi.fn() - // Act const { result } = renderHookWithProviders(() => useConfigurations({ getFieldValue: mockGetFieldValue, @@ -726,18 +574,13 @@ describe('useConfigurations', () => { }), ) - // Assert const typeConfig = result.current.find(config => config.variable === 'type') expect(typeConfig?.supportFile).toBe(true) }) }) - // ------------------------------------------------------------------------- - // Callback Tests - // ------------------------------------------------------------------------- describe('Callbacks', () => { it('should call setFieldValue when type changes to singleFile', () => { - // Arrange const mockGetFieldValue = vi.fn() const mockSetFieldValue = vi.fn() @@ -749,17 +592,14 @@ describe('useConfigurations', () => { }), ) - // Act const typeConfig = result.current.find(config => config.variable === 'type') typeConfig?.listeners?.onChange?.(createMockEvent(PipelineInputVarType.singleFile)) - // Assert expect(mockSetFieldValue).toHaveBeenCalledWith('allowedFileUploadMethods', expect.any(Array)) expect(mockSetFieldValue).toHaveBeenCalledWith('allowedTypesAndExtensions', expect.any(Object)) }) it('should call setFieldValue when type changes to multiFiles', () => { - // Arrange const mockGetFieldValue = vi.fn() const mockSetFieldValue = vi.fn() @@ -771,16 +611,13 @@ describe('useConfigurations', () => { }), ) - // Act const typeConfig = result.current.find(config => config.variable === 'type') typeConfig?.listeners?.onChange?.(createMockEvent(PipelineInputVarType.multiFiles)) - // Assert expect(mockSetFieldValue).toHaveBeenCalledWith('maxLength', expect.any(Number)) }) it('should set label from variable name on blur when label is empty', () => { - // Arrange const mockGetFieldValue = vi.fn().mockReturnValue('') const mockSetFieldValue = vi.fn() @@ -792,16 +629,13 @@ describe('useConfigurations', () => { }), ) - // Act const variableConfig = result.current.find(config => config.variable === 'variable') variableConfig?.listeners?.onBlur?.(createMockEvent('test_variable')) - // Assert expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'test_variable') }) it('should not set label from variable name on blur when label is not empty', () => { - // Arrange const mockGetFieldValue = vi.fn().mockReturnValue('Existing Label') const mockSetFieldValue = vi.fn() @@ -813,16 +647,13 @@ describe('useConfigurations', () => { }), ) - // Act const variableConfig = result.current.find(config => config.variable === 'variable') variableConfig?.listeners?.onBlur?.(createMockEvent('test_variable')) - // Assert expect(mockSetFieldValue).not.toHaveBeenCalled() }) it('should reset label to variable name when display name is cleared', () => { - // Arrange const mockGetFieldValue = vi.fn().mockReturnValue('original_var') const mockSetFieldValue = vi.fn() @@ -834,25 +665,18 @@ describe('useConfigurations', () => { }), ) - // Act const labelConfig = result.current.find(config => config.variable === 'label') labelConfig?.listeners?.onBlur?.(createMockEvent('')) - // Assert expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'original_var') }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should return configurations array with correct length', () => { - // Arrange const mockGetFieldValue = vi.fn() const mockSetFieldValue = vi.fn() - // Act const { result } = renderHookWithProviders(() => useConfigurations({ getFieldValue: mockGetFieldValue, @@ -861,77 +685,59 @@ describe('useConfigurations', () => { }), ) - // Assert - should have all expected field configurations expect(result.current.length).toBe(8) // type, variable, label, maxLength, options, fileTypes x2, required }) }) }) -// ============================================================================ -// useHiddenConfigurations Hook Tests -// ============================================================================ - describe('useHiddenConfigurations', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Configuration Generation Tests - // ------------------------------------------------------------------------- describe('Configuration Generation', () => { it('should return array of hidden configurations', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options: undefined }), ) - // Assert expect(Array.isArray(result.current)).toBe(true) expect(result.current.length).toBeGreaterThan(0) }) it('should include default value configurations for different types', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options: undefined }), ) - // Assert const defaultConfigs = result.current.filter(config => config.variable === 'default') expect(defaultConfigs.length).toBeGreaterThan(0) }) it('should include tooltips configuration', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options: undefined }), ) - // Assert const tooltipsConfig = result.current.find(config => config.variable === 'tooltips') expect(tooltipsConfig).toBeDefined() expect(tooltipsConfig?.showConditions).toEqual([]) }) it('should include placeholder configurations', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options: undefined }), ) - // Assert const placeholderConfigs = result.current.filter(config => config.variable === 'placeholder') expect(placeholderConfigs.length).toBeGreaterThan(0) }) it('should include unit configuration for number type', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options: undefined }), ) - // Assert const unitConfig = result.current.find(config => config.variable === 'unit') expect(unitConfig).toBeDefined() expect(unitConfig?.showConditions).toContainEqual({ @@ -941,12 +747,10 @@ describe('useHiddenConfigurations', () => { }) it('should include upload method configurations for file types', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options: undefined }), ) - // Assert const uploadMethodConfigs = result.current.filter( config => config.variable === 'allowedFileUploadMethods', ) @@ -954,12 +758,10 @@ describe('useHiddenConfigurations', () => { }) it('should include maxLength configuration for multiFiles', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options: undefined }), ) - // Assert const maxLengthConfig = result.current.find( config => config.variable === 'maxLength' && config.showConditions?.some(c => c.value === PipelineInputVarType.multiFiles), @@ -968,20 +770,14 @@ describe('useHiddenConfigurations', () => { }) }) - // ------------------------------------------------------------------------- - // Options Handling Tests - // ------------------------------------------------------------------------- describe('Options Handling', () => { it('should generate select options from provided options array', () => { - // Arrange const options = ['Option A', 'Option B', 'Option C'] - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options }), ) - // Assert const selectConfig = result.current.find( config => config.variable === 'default' && config.showConditions?.some(c => c.value === PipelineInputVarType.select), @@ -991,15 +787,12 @@ describe('useHiddenConfigurations', () => { }) it('should include "no default selected" option', () => { - // Arrange const options = ['Option A'] - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options }), ) - // Assert const selectConfig = result.current.find( config => config.variable === 'default' && config.showConditions?.some(c => c.value === PipelineInputVarType.select), @@ -1009,12 +802,10 @@ describe('useHiddenConfigurations', () => { }) it('should return empty options when options is undefined', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options: undefined }), ) - // Assert const selectConfig = result.current.find( config => config.variable === 'default' && config.showConditions?.some(c => c.value === PipelineInputVarType.select), @@ -1023,17 +814,12 @@ describe('useHiddenConfigurations', () => { }) }) - // ------------------------------------------------------------------------- - // File Size Limit Integration Tests - // ------------------------------------------------------------------------- describe('File Size Limit Integration', () => { it('should include file size description in maxLength config', () => { - // Act const { result } = renderHookWithProviders(() => useHiddenConfigurations({ options: undefined }), ) - // Assert const maxLengthConfig = result.current.find( config => config.variable === 'maxLength' && config.showConditions?.some(c => c.value === PipelineInputVarType.multiFiles), @@ -1043,36 +829,24 @@ describe('useHiddenConfigurations', () => { }) }) -// ============================================================================ -// Schema Validation Tests -// ============================================================================ - describe('createInputFieldSchema', () => { - // Mock translation function - cast to any to satisfy TFunction type requirements const mockT = ((key: string) => key) as unknown as Parameters<typeof createInputFieldSchema>[1] beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Common Schema Tests - // ------------------------------------------------------------------------- describe('Common Schema Validation', () => { it('should validate required variable field', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) const invalidData = { variable: '', label: 'Test', required: true, type: 'text-input' } - // Act const result = schema.safeParse(invalidData) - // Assert expect(result.success).toBe(false) }) it('should validate variable max length', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) const invalidData = { variable: 'a'.repeat(100), @@ -1082,15 +856,12 @@ describe('createInputFieldSchema', () => { maxLength: 48, } - // Act const result = schema.safeParse(invalidData) - // Assert expect(result.success).toBe(false) }) it('should validate variable does not start with number', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) const invalidData = { variable: '123var', @@ -1100,15 +871,12 @@ describe('createInputFieldSchema', () => { maxLength: 48, } - // Act const result = schema.safeParse(invalidData) - // Assert expect(result.success).toBe(false) }) it('should validate variable format (alphanumeric and underscore)', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) const invalidData = { variable: 'var-name', @@ -1118,15 +886,12 @@ describe('createInputFieldSchema', () => { maxLength: 48, } - // Act const result = schema.safeParse(invalidData) - // Assert expect(result.success).toBe(false) }) it('should accept valid variable name', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'valid_var_123', @@ -1136,15 +901,12 @@ describe('createInputFieldSchema', () => { maxLength: 48, } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) it('should validate required label field', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) const invalidData = { variable: 'test_var', @@ -1154,20 +916,14 @@ describe('createInputFieldSchema', () => { maxLength: 48, } - // Act const result = schema.safeParse(invalidData) - // Assert expect(result.success).toBe(false) }) }) - // ------------------------------------------------------------------------- - // Text Input Schema Tests - // ------------------------------------------------------------------------- describe('Text Input Schema', () => { it('should validate maxLength within bounds', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'test_var', @@ -1177,15 +933,12 @@ describe('createInputFieldSchema', () => { maxLength: 100, } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) it('should reject maxLength exceeding TEXT_MAX_LENGTH', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) const invalidData = { variable: 'test_var', @@ -1195,15 +948,12 @@ describe('createInputFieldSchema', () => { maxLength: TEXT_MAX_LENGTH + 1, } - // Act const result = schema.safeParse(invalidData) - // Assert expect(result.success).toBe(false) }) it('should reject maxLength less than 1', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) const invalidData = { variable: 'test_var', @@ -1213,15 +963,12 @@ describe('createInputFieldSchema', () => { maxLength: 0, } - // Act const result = schema.safeParse(invalidData) - // Assert expect(result.success).toBe(false) }) it('should allow optional default value', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.textInput, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'test_var', @@ -1232,20 +979,14 @@ describe('createInputFieldSchema', () => { default: 'default value', } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) }) - // ------------------------------------------------------------------------- - // Paragraph Schema Tests - // ------------------------------------------------------------------------- describe('Paragraph Schema', () => { it('should validate paragraph type similar to textInput', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.paragraph, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'test_var', @@ -1255,20 +996,14 @@ describe('createInputFieldSchema', () => { maxLength: 100, } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) }) - // ------------------------------------------------------------------------- - // Number Schema Tests - // ------------------------------------------------------------------------- describe('Number Schema', () => { it('should allow optional default number', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.number, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'test_var', @@ -1278,15 +1013,12 @@ describe('createInputFieldSchema', () => { default: 42, } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) it('should allow optional unit', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.number, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'test_var', @@ -1296,20 +1028,14 @@ describe('createInputFieldSchema', () => { unit: 'kg', } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) }) - // ------------------------------------------------------------------------- - // Select Schema Tests - // ------------------------------------------------------------------------- describe('Select Schema', () => { it('should require non-empty options array', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.select, mockT, { maxFileUploadLimit: 10 }) const invalidData = { variable: 'test_var', @@ -1319,15 +1045,12 @@ describe('createInputFieldSchema', () => { options: [], } - // Act const result = schema.safeParse(invalidData) - // Assert expect(result.success).toBe(false) }) it('should accept valid options array', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.select, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'test_var', @@ -1337,15 +1060,12 @@ describe('createInputFieldSchema', () => { options: ['Option 1', 'Option 2'], } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) it('should reject duplicate options', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.select, mockT, { maxFileUploadLimit: 10 }) const invalidData = { variable: 'test_var', @@ -1355,20 +1075,14 @@ describe('createInputFieldSchema', () => { options: ['Option 1', 'Option 1'], } - // Act const result = schema.safeParse(invalidData) - // Assert expect(result.success).toBe(false) }) }) - // ------------------------------------------------------------------------- - // Single File Schema Tests - // ------------------------------------------------------------------------- describe('Single File Schema', () => { it('should validate allowedFileUploadMethods', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.singleFile, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'test_var', @@ -1381,15 +1095,12 @@ describe('createInputFieldSchema', () => { }, } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) it('should validate allowedTypesAndExtensions', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.singleFile, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'test_var', @@ -1403,20 +1114,14 @@ describe('createInputFieldSchema', () => { }, } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) }) - // ------------------------------------------------------------------------- - // Multi Files Schema Tests - // ------------------------------------------------------------------------- describe('Multi Files Schema', () => { it('should validate maxLength within file upload limit', () => { - // Arrange const maxFileUploadLimit = 10 const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, mockT, { maxFileUploadLimit }) const validData = { @@ -1431,15 +1136,12 @@ describe('createInputFieldSchema', () => { maxLength: 5, } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) it('should reject maxLength exceeding file upload limit', () => { - // Arrange const maxFileUploadLimit = 10 const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, mockT, { maxFileUploadLimit }) const invalidData = { @@ -1454,20 +1156,14 @@ describe('createInputFieldSchema', () => { maxLength: 15, } - // Act const result = schema.safeParse(invalidData) - // Assert expect(result.success).toBe(false) }) }) - // ------------------------------------------------------------------------- - // Default Schema Tests (for checkbox and other types) - // ------------------------------------------------------------------------- describe('Default Schema', () => { it('should validate checkbox type with common schema', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.checkbox, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'test_var', @@ -1476,15 +1172,12 @@ describe('createInputFieldSchema', () => { type: 'checkbox', } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) }) it('should allow passthrough of additional fields', () => { - // Arrange const schema = createInputFieldSchema(PipelineInputVarType.checkbox, mockT, { maxFileUploadLimit: 10 }) const validData = { variable: 'test_var', @@ -1494,10 +1187,8 @@ describe('createInputFieldSchema', () => { extraField: 'extra value', } - // Act const result = schema.safeParse(validData) - // Assert expect(result.success).toBe(true) if (result.success) { expect((result.data as Record<string, unknown>).extraField).toBe('extra value') @@ -1506,14 +1197,9 @@ describe('createInputFieldSchema', () => { }) }) -// ============================================================================ -// Types Tests -// ============================================================================ - describe('Types', () => { describe('FormData type', () => { it('should have correct structure', () => { - // This is a compile-time check, but we can verify at runtime too const formData: FormData = { type: PipelineInputVarType.textInput, label: 'Test', @@ -1584,10 +1270,6 @@ describe('Types', () => { }) }) -// ============================================================================ -// TEXT_MAX_LENGTH Constant Tests -// ============================================================================ - describe('TEXT_MAX_LENGTH', () => { it('should be a positive number', () => { expect(TEXT_MAX_LENGTH).toBeGreaterThan(0) @@ -1598,78 +1280,55 @@ describe('TEXT_MAX_LENGTH', () => { }) }) -// ============================================================================ -// InitialFields Component Tests -// ============================================================================ - describe('InitialFields', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render InitialFields component without crashing', () => { - // Arrange const initialData = createFormData() const props = createInputFieldFormProps({ initialData }) - // Act const { container } = renderWithProviders(<InputFieldForm {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // getFieldValue and setFieldValue Callbacks Tests - // ------------------------------------------------------------------------- describe('getFieldValue and setFieldValue Callbacks', () => { it('should trigger getFieldValue when variable name blur event fires with empty label', async () => { - // Arrange - Create initial data with empty label const initialData = createFormData({ variable: '', label: '', // Empty label to trigger the condition }) const props = createInputFieldFormProps({ initialData }) - // Act renderWithProviders(<InputFieldForm {...props} />) - // Find the variable input and trigger blur with a value const variableInput = screen.getByLabelText('appDebug.variableConfig.varName') fireEvent.change(variableInput, { target: { value: 'test_var' } }) fireEvent.blur(variableInput) - // Assert - The label field should be updated via setFieldValue when variable blurs - // The getFieldValue is called to check if label is empty await waitFor(() => { const labelInput = screen.getByLabelText('appDebug.variableConfig.displayName') - // Label should be set to the variable value when it was empty expect(labelInput).toHaveValue('test_var') }) }) it('should not update label when it already has a value on variable blur', async () => { - // Arrange - Create initial data with existing label const initialData = createFormData({ variable: '', label: 'Existing Label', // Label already has value }) const props = createInputFieldFormProps({ initialData }) - // Act renderWithProviders(<InputFieldForm {...props} />) - // Find the variable input and trigger blur with a value const variableInput = screen.getByLabelText('appDebug.variableConfig.varName') fireEvent.change(variableInput, { target: { value: 'new_var' } }) fireEvent.blur(variableInput) - // Assert - The label field should remain unchanged because it already has a value await waitFor(() => { const labelInput = screen.getByLabelText('appDebug.variableConfig.displayName') expect(labelInput).toHaveValue('Existing Label') @@ -1677,44 +1336,36 @@ describe('InitialFields', () => { }) it('should trigger setFieldValue when display name blur event fires with empty value', async () => { - // Arrange - Create initial data with a variable but we will clear the label const initialData = createFormData({ variable: 'original_var', label: 'Some Label', }) const props = createInputFieldFormProps({ initialData }) - // Act renderWithProviders(<InputFieldForm {...props} />) - // Find the label input, clear it, and trigger blur const labelInput = screen.getByLabelText('appDebug.variableConfig.displayName') fireEvent.change(labelInput, { target: { value: '' } }) fireEvent.blur(labelInput) - // Assert - When label is cleared and blurred, it should be reset to variable name await waitFor(() => { expect(labelInput).toHaveValue('original_var') }) }) it('should keep label value when display name blur event fires with non-empty value', async () => { - // Arrange const initialData = createFormData({ variable: 'test_var', label: 'Original Label', }) const props = createInputFieldFormProps({ initialData }) - // Act renderWithProviders(<InputFieldForm {...props} />) - // Find the label input, change it to a new value, and trigger blur const labelInput = screen.getByLabelText('appDebug.variableConfig.displayName') fireEvent.change(labelInput, { target: { value: 'New Label' } }) fireEvent.blur(labelInput) - // Assert - Label should keep the new non-empty value await waitFor(() => { expect(labelInput).toHaveValue('New Label') }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/schema.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/schema.spec.ts new file mode 100644 index 0000000000..d554f9653e --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/schema.spec.ts @@ -0,0 +1,260 @@ +import type { TFunction } from 'i18next' +import { describe, expect, it, vi } from 'vitest' +import { PipelineInputVarType } from '@/models/pipeline' +import { createInputFieldSchema, TEXT_MAX_LENGTH } from '../schema' + +vi.mock('@/config', () => ({ + MAX_VAR_KEY_LENGTH: 30, +})) + +const t: TFunction = ((key: string) => key) as unknown as TFunction + +const defaultOptions = { maxFileUploadLimit: 10 } + +describe('TEXT_MAX_LENGTH', () => { + it('should be 256', () => { + expect(TEXT_MAX_LENGTH).toBe(256) + }) +}) + +describe('createInputFieldSchema', () => { + describe('common schema validation', () => { + it('should reject empty variable name', () => { + const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions) + const result = schema.safeParse({ + type: 'text-input', + variable: '', + label: 'Test', + required: false, + maxLength: 48, + }) + + expect(result.success).toBe(false) + }) + + it('should reject variable starting with number', () => { + const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions) + const result = schema.safeParse({ + type: 'text-input', + variable: '123abc', + label: 'Test', + required: false, + maxLength: 48, + }) + + expect(result.success).toBe(false) + }) + + it('should accept valid variable name', () => { + const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions) + const result = schema.safeParse({ + type: 'text-input', + variable: 'valid_var', + label: 'Test', + required: false, + maxLength: 48, + }) + + expect(result.success).toBe(true) + }) + + it('should reject empty label', () => { + const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions) + const result = schema.safeParse({ + type: 'text-input', + variable: 'my_var', + label: '', + required: false, + maxLength: 48, + }) + + expect(result.success).toBe(false) + }) + }) + + describe('text input type', () => { + it('should validate maxLength within range', () => { + const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions) + + const valid = schema.safeParse({ + type: 'text-input', + variable: 'text_var', + label: 'Text', + required: false, + maxLength: 100, + }) + expect(valid.success).toBe(true) + + const tooLow = schema.safeParse({ + type: 'text-input', + variable: 'text_var', + label: 'Text', + required: false, + maxLength: 0, + }) + expect(tooLow.success).toBe(false) + }) + + it('should allow optional default and tooltips', () => { + const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions) + const result = schema.safeParse({ + type: 'text-input', + variable: 'text_var', + label: 'Text', + required: false, + maxLength: 48, + default: 'default value', + tooltips: 'Some help text', + }) + + expect(result.success).toBe(true) + }) + }) + + describe('paragraph type', () => { + it('should use same schema as text input', () => { + const schema = createInputFieldSchema(PipelineInputVarType.paragraph, t, defaultOptions) + const result = schema.safeParse({ + type: 'paragraph', + variable: 'para_var', + label: 'Paragraph', + required: false, + maxLength: 100, + }) + + expect(result.success).toBe(true) + }) + }) + + describe('number type', () => { + it('should allow optional unit and placeholder', () => { + const schema = createInputFieldSchema(PipelineInputVarType.number, t, defaultOptions) + const result = schema.safeParse({ + type: 'number', + variable: 'num_var', + label: 'Number', + required: false, + default: 42, + unit: 'kg', + placeholder: 'Enter weight', + }) + + expect(result.success).toBe(true) + }) + }) + + describe('select type', () => { + it('should require non-empty options array', () => { + const schema = createInputFieldSchema(PipelineInputVarType.select, t, defaultOptions) + + const empty = schema.safeParse({ + type: 'select', + variable: 'sel_var', + label: 'Select', + required: false, + options: [], + }) + expect(empty.success).toBe(false) + + const valid = schema.safeParse({ + type: 'select', + variable: 'sel_var', + label: 'Select', + required: false, + options: ['opt1', 'opt2'], + }) + expect(valid.success).toBe(true) + }) + + it('should reject duplicate options', () => { + const schema = createInputFieldSchema(PipelineInputVarType.select, t, defaultOptions) + const result = schema.safeParse({ + type: 'select', + variable: 'sel_var', + label: 'Select', + required: false, + options: ['opt1', 'opt1'], + }) + + expect(result.success).toBe(false) + }) + }) + + describe('singleFile type', () => { + it('should require file upload methods and types', () => { + const schema = createInputFieldSchema(PipelineInputVarType.singleFile, t, defaultOptions) + const result = schema.safeParse({ + type: 'file', + variable: 'file_var', + label: 'File', + required: false, + allowedFileUploadMethods: ['local_file'], + allowedTypesAndExtensions: { + allowedFileTypes: ['document'], + }, + }) + + expect(result.success).toBe(true) + }) + }) + + describe('multiFiles type', () => { + it('should validate maxLength against maxFileUploadLimit', () => { + const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, t, { maxFileUploadLimit: 5 }) + + const valid = schema.safeParse({ + type: 'file-list', + variable: 'files_var', + label: 'Files', + required: false, + allowedFileUploadMethods: ['local_file'], + allowedTypesAndExtensions: { + allowedFileTypes: ['image'], + }, + maxLength: 3, + }) + expect(valid.success).toBe(true) + + const tooMany = schema.safeParse({ + type: 'file-list', + variable: 'files_var', + label: 'Files', + required: false, + allowedFileUploadMethods: ['local_file'], + allowedTypesAndExtensions: { + allowedFileTypes: ['image'], + }, + maxLength: 10, + }) + expect(tooMany.success).toBe(false) + }) + }) + + describe('checkbox / default type', () => { + it('should use common schema for checkbox type', () => { + const schema = createInputFieldSchema(PipelineInputVarType.checkbox, t, defaultOptions) + const result = schema.safeParse({ + type: 'checkbox', + variable: 'check_var', + label: 'Agree', + required: true, + }) + + expect(result.success).toBe(true) + }) + + it('should allow passthrough of extra fields', () => { + const schema = createInputFieldSchema(PipelineInputVarType.checkbox, t, defaultOptions) + const result = schema.safeParse({ + type: 'checkbox', + variable: 'check_var', + label: 'Agree', + required: true, + default: true, + extraField: 'should pass through', + }) + + expect(result.success).toBe(true) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..f53222c7d5 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts @@ -0,0 +1,371 @@ +import type { InputVar } from '@/models/pipeline' +import { renderHook } from '@testing-library/react' +import { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useFieldList } from '../hooks' + +const mockToggleInputFieldEditPanel = vi.fn() +vi.mock('@/app/components/rag-pipeline/hooks', () => ({ + useInputFieldPanel: () => ({ + toggleInputFieldEditPanel: mockToggleInputFieldEditPanel, + }), +})) + +const mockHandleInputVarRename = vi.fn() +const mockIsVarUsedInNodes = vi.fn() +const mockRemoveUsedVarInNodes = vi.fn() +vi.mock('../../../../../hooks/use-pipeline', () => ({ + usePipeline: () => ({ + handleInputVarRename: mockHandleInputVarRename, + isVarUsedInNodes: mockIsVarUsedInNodes, + removeUsedVarInNodes: mockRemoveUsedVarInNodes, + }), +})) + +const mockToastNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: (...args: unknown[]) => mockToastNotify(...args), + }, +})) + +vi.mock('@/app/components/workflow/types', () => ({ + ChangeType: { + changeVarName: 'changeVarName', + remove: 'remove', + }, +})) + +function createInputVar(overrides?: Partial<InputVar>): InputVar { + return { + type: 'text-input', + variable: 'test_var', + label: 'Test Var', + required: false, + ...overrides, + } as InputVar +} + +function createDefaultProps(overrides?: Partial<Parameters<typeof useFieldList>[0]>) { + return { + initialInputFields: [] as InputVar[], + onInputFieldsChange: vi.fn(), + nodeId: 'node-1', + allVariableNames: [] as string[], + ...overrides, + } +} + +describe('useFieldList', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsVarUsedInNodes.mockReturnValue(false) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('initialization', () => { + it('should return inputFields from initialInputFields', () => { + const fields = [createInputVar({ variable: 'var1' })] + const { result } = renderHook(() => useFieldList(createDefaultProps({ initialInputFields: fields }))) + + expect(result.current.inputFields).toEqual(fields) + }) + + it('should return empty inputFields when initialized with empty array', () => { + const { result } = renderHook(() => useFieldList(createDefaultProps())) + + expect(result.current.inputFields).toEqual([]) + }) + + it('should return all expected functions', () => { + const { result } = renderHook(() => useFieldList(createDefaultProps())) + + expect(typeof result.current.handleListSortChange).toBe('function') + expect(typeof result.current.handleRemoveField).toBe('function') + expect(typeof result.current.handleOpenInputFieldEditor).toBe('function') + expect(typeof result.current.hideRemoveVarConfirm).toBe('function') + expect(typeof result.current.onRemoveVarConfirm).toBe('function') + }) + + it('should have isShowRemoveVarConfirm as false initially', () => { + const { result } = renderHook(() => useFieldList(createDefaultProps())) + + expect(result.current.isShowRemoveVarConfirm).toBe(false) + }) + }) + + describe('handleListSortChange', () => { + it('should reorder input fields and notify parent', () => { + const var1 = createInputVar({ variable: 'var1', label: 'V1' }) + const var2 = createInputVar({ variable: 'var2', label: 'V2' }) + const onInputFieldsChange = vi.fn() + const { result } = renderHook(() => + useFieldList(createDefaultProps({ + initialInputFields: [var1, var2], + onInputFieldsChange, + })), + ) + + act(() => { + result.current.handleListSortChange([ + { ...var2, id: '1', chosen: false, selected: false }, + { ...var1, id: '0', chosen: false, selected: false }, + ]) + }) + + expect(onInputFieldsChange).toHaveBeenCalledWith([var2, var1]) + }) + + it('should strip sortable metadata (id, chosen, selected) from items', () => { + const var1 = createInputVar({ variable: 'var1' }) + const onInputFieldsChange = vi.fn() + const { result } = renderHook(() => + useFieldList(createDefaultProps({ + initialInputFields: [var1], + onInputFieldsChange, + })), + ) + + act(() => { + result.current.handleListSortChange([ + { ...var1, id: '0', chosen: true, selected: true }, + ]) + }) + + const updatedFields = onInputFieldsChange.mock.calls[0][0] + expect(updatedFields[0]).not.toHaveProperty('id') + expect(updatedFields[0]).not.toHaveProperty('chosen') + expect(updatedFields[0]).not.toHaveProperty('selected') + }) + }) + + describe('handleRemoveField', () => { + it('should remove field when variable is not used in nodes', () => { + const var1 = createInputVar({ variable: 'var1' }) + const var2 = createInputVar({ variable: 'var2' }) + const onInputFieldsChange = vi.fn() + mockIsVarUsedInNodes.mockReturnValue(false) + + const { result } = renderHook(() => + useFieldList(createDefaultProps({ + initialInputFields: [var1, var2], + onInputFieldsChange, + })), + ) + + act(() => { + result.current.handleRemoveField(0) + }) + + expect(onInputFieldsChange).toHaveBeenCalledWith([var2]) + }) + + it('should show confirmation when variable is used in other nodes', () => { + const var1 = createInputVar({ variable: 'used_var' }) + const onInputFieldsChange = vi.fn() + mockIsVarUsedInNodes.mockReturnValue(true) + + const { result } = renderHook(() => + useFieldList(createDefaultProps({ + initialInputFields: [var1], + onInputFieldsChange, + })), + ) + + act(() => { + result.current.handleRemoveField(0) + }) + + expect(result.current.isShowRemoveVarConfirm).toBe(true) + expect(onInputFieldsChange).not.toHaveBeenCalled() + }) + }) + + describe('onRemoveVarConfirm', () => { + it('should remove field and clean up variable references after confirmation', () => { + const var1 = createInputVar({ variable: 'used_var' }) + const onInputFieldsChange = vi.fn() + mockIsVarUsedInNodes.mockReturnValue(true) + + const { result } = renderHook(() => + useFieldList(createDefaultProps({ + initialInputFields: [var1], + onInputFieldsChange, + nodeId: 'node-1', + })), + ) + + act(() => { + result.current.handleRemoveField(0) + }) + + expect(result.current.isShowRemoveVarConfirm).toBe(true) + + act(() => { + result.current.onRemoveVarConfirm() + }) + + expect(onInputFieldsChange).toHaveBeenCalledWith([]) + expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['rag', 'node-1', 'used_var']) + expect(result.current.isShowRemoveVarConfirm).toBe(false) + }) + }) + + describe('handleOpenInputFieldEditor', () => { + it('should open editor with existing field data when id matches', () => { + const var1 = createInputVar({ variable: 'var1', label: 'Label 1' }) + const { result } = renderHook(() => + useFieldList(createDefaultProps({ initialInputFields: [var1] })), + ) + + act(() => { + result.current.handleOpenInputFieldEditor('var1') + }) + + expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith( + expect.objectContaining({ + initialData: var1, + }), + ) + }) + + it('should open editor for new field when id does not match', () => { + const { result } = renderHook(() => + useFieldList(createDefaultProps()), + ) + + act(() => { + result.current.handleOpenInputFieldEditor('non-existent') + }) + + expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith( + expect.objectContaining({ + initialData: undefined, + }), + ) + }) + + it('should open editor for new field when no id provided', () => { + const { result } = renderHook(() => + useFieldList(createDefaultProps()), + ) + + act(() => { + result.current.handleOpenInputFieldEditor() + }) + + expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith( + expect.objectContaining({ + initialData: undefined, + }), + ) + }) + }) + + describe('field submission (via editor)', () => { + it('should add new field when editingFieldIndex is -1', () => { + const onInputFieldsChange = vi.fn() + const { result } = renderHook(() => + useFieldList(createDefaultProps({ onInputFieldsChange })), + ) + + act(() => { + result.current.handleOpenInputFieldEditor() + }) + + const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] + const newField = createInputVar({ variable: 'new_var', label: 'New' }) + + act(() => { + editorProps.onSubmit(newField) + }) + + expect(onInputFieldsChange).toHaveBeenCalledWith([newField]) + }) + + it('should show error toast for duplicate variable names', () => { + const var1 = createInputVar({ variable: 'existing_var' }) + const onInputFieldsChange = vi.fn() + const { result } = renderHook(() => + useFieldList(createDefaultProps({ + initialInputFields: [var1], + onInputFieldsChange, + allVariableNames: ['existing_var'], + })), + ) + + act(() => { + result.current.handleOpenInputFieldEditor() + }) + + const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] + const duplicateField = createInputVar({ variable: 'existing_var' }) + + act(() => { + editorProps.onSubmit(duplicateField) + }) + + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + expect(onInputFieldsChange).not.toHaveBeenCalled() + }) + + it('should trigger variable rename when ChangeType is changeVarName', () => { + const var1 = createInputVar({ variable: 'old_name' }) + const onInputFieldsChange = vi.fn() + const { result } = renderHook(() => + useFieldList(createDefaultProps({ + initialInputFields: [var1], + onInputFieldsChange, + nodeId: 'node-1', + allVariableNames: ['old_name'], + })), + ) + + act(() => { + result.current.handleOpenInputFieldEditor('old_name') + }) + + const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] + const updatedField = createInputVar({ variable: 'new_name' }) + + act(() => { + editorProps.onSubmit(updatedField, { + type: 'changeVarName', + payload: { beforeKey: 'old_name', afterKey: 'new_name' }, + }) + }) + + expect(mockHandleInputVarRename).toHaveBeenCalledWith( + 'node-1', + ['rag', 'node-1', 'old_name'], + ['rag', 'node-1', 'new_name'], + ) + }) + }) + + describe('hideRemoveVarConfirm', () => { + it('should hide the confirmation dialog', () => { + const var1 = createInputVar({ variable: 'used_var' }) + mockIsVarUsedInNodes.mockReturnValue(true) + + const { result } = renderHook(() => + useFieldList(createDefaultProps({ initialInputFields: [var1] })), + ) + + act(() => { + result.current.handleRemoveField(0) + }) + expect(result.current.isShowRemoveVarConfirm).toBe(true) + + act(() => { + result.current.hideRemoveVarConfirm() + }) + expect(result.current.isShowRemoveVarConfirm).toBe(false) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx similarity index 80% rename from web/app/components/rag-pipeline/components/panel/input-field/field-list/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx index f28173d2f1..b4332781a6 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx @@ -1,17 +1,12 @@ -import type { SortableItem } from './types' +import type { SortableItem } from '../types' import type { InputVar } from '@/models/pipeline' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { PipelineInputVarType } from '@/models/pipeline' -import FieldItem from './field-item' -import FieldListContainer from './field-list-container' -import FieldList from './index' +import FieldItem from '../field-item' +import FieldListContainer from '../field-list-container' +import FieldList from '../index' -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock ahooks useHover let mockIsHovering = false const getMockIsHovering = () => mockIsHovering @@ -23,7 +18,6 @@ vi.mock('ahooks', async (importOriginal) => { } }) -// Mock react-sortablejs vi.mock('react-sortablejs', () => ({ ReactSortable: ({ children, list, setList, disabled, className }: { children: React.ReactNode @@ -42,7 +36,6 @@ vi.mock('react-sortablejs', () => ({ data-testid="trigger-sort" onClick={() => { if (!disabled && list.length > 1) { - // Simulate reorder: swap first two items const newList = [...list] const temp = newList[0] newList[0] = newList[1] @@ -56,7 +49,6 @@ vi.mock('react-sortablejs', () => ({ <button data-testid="trigger-same-sort" onClick={() => { - // Trigger setList with same list (no actual change) setList([...list]) }} > @@ -66,12 +58,11 @@ vi.mock('react-sortablejs', () => ({ ), })) -// Mock usePipeline hook const mockHandleInputVarRename = vi.fn() const mockIsVarUsedInNodes = vi.fn(() => false) const mockRemoveUsedVarInNodes = vi.fn() -vi.mock('../../../../hooks/use-pipeline', () => ({ +vi.mock('../../../../../hooks/use-pipeline', () => ({ usePipeline: () => ({ handleInputVarRename: mockHandleInputVarRename, isVarUsedInNodes: mockIsVarUsedInNodes, @@ -79,7 +70,6 @@ vi.mock('../../../../hooks/use-pipeline', () => ({ }), })) -// Mock useInputFieldPanel hook const mockToggleInputFieldEditPanel = vi.fn() vi.mock('@/app/components/rag-pipeline/hooks', () => ({ @@ -88,14 +78,12 @@ vi.mock('@/app/components/rag-pipeline/hooks', () => ({ }), })) -// Mock Toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), }, })) -// Mock RemoveEffectVarConfirm vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm', () => ({ default: ({ isShow, @@ -115,10 +103,6 @@ vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-conf : null, })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createInputVar = (overrides?: Partial<InputVar>): InputVar => ({ type: PipelineInputVarType.textInput, label: 'Test Label', @@ -155,25 +139,16 @@ const createSortableItem = ( ...overrides, }) -// ============================================================================ -// FieldItem Component Tests -// ============================================================================ - describe('FieldItem', () => { beforeEach(() => { vi.clearAllMocks() mockIsHovering = false }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render field item with variable name', () => { - // Arrange const payload = createInputVar({ variable: 'my_field' }) - // Act render( <FieldItem payload={payload} @@ -183,15 +158,12 @@ describe('FieldItem', () => { />, ) - // Assert expect(screen.getByText('my_field')).toBeInTheDocument() }) it('should render field item with label when provided', () => { - // Arrange const payload = createInputVar({ variable: 'field', label: 'Field Label' }) - // Act render( <FieldItem payload={payload} @@ -201,15 +173,12 @@ describe('FieldItem', () => { />, ) - // Assert expect(screen.getByText('Field Label')).toBeInTheDocument() }) it('should not render label when empty', () => { - // Arrange const payload = createInputVar({ variable: 'field', label: '' }) - // Act render( <FieldItem payload={payload} @@ -219,16 +188,13 @@ describe('FieldItem', () => { />, ) - // Assert expect(screen.queryByText('·')).not.toBeInTheDocument() }) it('should render required badge when not hovering and required is true', () => { - // Arrange mockIsHovering = false const payload = createInputVar({ required: true }) - // Act render( <FieldItem payload={payload} @@ -238,16 +204,13 @@ describe('FieldItem', () => { />, ) - // Assert expect(screen.getByText(/required/i)).toBeInTheDocument() }) it('should not render required badge when required is false', () => { - // Arrange mockIsHovering = false const payload = createInputVar({ required: false }) - // Act render( <FieldItem payload={payload} @@ -257,16 +220,13 @@ describe('FieldItem', () => { />, ) - // Assert expect(screen.queryByText(/required/i)).not.toBeInTheDocument() }) it('should render InputField icon when not hovering', () => { - // Arrange mockIsHovering = false const payload = createInputVar() - // Act const { container } = render( <FieldItem payload={payload} @@ -276,17 +236,14 @@ describe('FieldItem', () => { />, ) - // Assert - InputField icon should be present (not RiDraggable) const icons = container.querySelectorAll('svg') expect(icons.length).toBeGreaterThan(0) }) it('should render drag icon when hovering and not readonly', () => { - // Arrange mockIsHovering = true const payload = createInputVar() - // Act const { container } = render( <FieldItem payload={payload} @@ -297,17 +254,14 @@ describe('FieldItem', () => { />, ) - // Assert - RiDraggable icon should be present const icons = container.querySelectorAll('svg') expect(icons.length).toBeGreaterThan(0) }) it('should render edit and delete buttons when hovering and not readonly', () => { - // Arrange mockIsHovering = true const payload = createInputVar() - // Act render( <FieldItem payload={payload} @@ -318,17 +272,14 @@ describe('FieldItem', () => { />, ) - // Assert const buttons = screen.getAllByRole('button') expect(buttons.length).toBe(2) // Edit and Delete buttons }) it('should not render edit and delete buttons when readonly', () => { - // Arrange mockIsHovering = true const payload = createInputVar() - // Act render( <FieldItem payload={payload} @@ -339,23 +290,17 @@ describe('FieldItem', () => { />, ) - // Assert const buttons = screen.queryAllByRole('button') expect(buttons.length).toBe(0) }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onClickEdit with variable when edit button is clicked', () => { - // Arrange mockIsHovering = true const onClickEdit = vi.fn() const payload = createInputVar({ variable: 'test_var' }) - // Act render( <FieldItem payload={payload} @@ -367,17 +312,14 @@ describe('FieldItem', () => { const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]) // Edit button - // Assert expect(onClickEdit).toHaveBeenCalledWith('test_var') }) it('should call onRemove with index when delete button is clicked', () => { - // Arrange mockIsHovering = true const onRemove = vi.fn() const payload = createInputVar() - // Act render( <FieldItem payload={payload} @@ -389,17 +331,14 @@ describe('FieldItem', () => { const buttons = screen.getAllByRole('button') fireEvent.click(buttons[1]) // Delete button - // Assert expect(onRemove).toHaveBeenCalledWith(5) }) it('should not call onClickEdit when readonly', () => { - // Arrange mockIsHovering = true const onClickEdit = vi.fn() const payload = createInputVar() - // Render without readonly to get buttons, then check behavior const { rerender } = render( <FieldItem payload={payload} @@ -410,7 +349,6 @@ describe('FieldItem', () => { />, ) - // Re-render with readonly but buttons still exist from previous state check rerender( <FieldItem payload={payload} @@ -421,18 +359,15 @@ describe('FieldItem', () => { />, ) - // Assert - no buttons should be rendered when readonly expect(screen.queryAllByRole('button').length).toBe(0) }) it('should stop event propagation when edit button is clicked', () => { - // Arrange mockIsHovering = true const onClickEdit = vi.fn() const parentClick = vi.fn() const payload = createInputVar() - // Act render( <div onClick={parentClick}> <FieldItem @@ -446,19 +381,16 @@ describe('FieldItem', () => { const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]) - // Assert - parent click should not be called due to stopPropagation expect(onClickEdit).toHaveBeenCalled() expect(parentClick).not.toHaveBeenCalled() }) it('should stop event propagation when delete button is clicked', () => { - // Arrange mockIsHovering = true const onRemove = vi.fn() const parentClick = vi.fn() const payload = createInputVar() - // Act render( <div onClick={parentClick}> <FieldItem @@ -472,23 +404,17 @@ describe('FieldItem', () => { const buttons = screen.getAllByRole('button') fireEvent.click(buttons[1]) - // Assert expect(onRemove).toHaveBeenCalled() expect(parentClick).not.toHaveBeenCalled() }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should maintain stable handleOnClickEdit when props dont change', () => { - // Arrange mockIsHovering = true const onClickEdit = vi.fn() const payload = createInputVar() - // Act const { rerender } = render( <FieldItem payload={payload} @@ -511,21 +437,15 @@ describe('FieldItem', () => { const buttonsAfterRerender = screen.getAllByRole('button') fireEvent.click(buttonsAfterRerender[0]) - // Assert expect(onClickEdit).toHaveBeenCalledTimes(2) }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle very long variable names with truncation', () => { - // Arrange const longVariable = 'a'.repeat(200) const payload = createInputVar({ variable: longVariable }) - // Act render( <FieldItem payload={payload} @@ -535,17 +455,14 @@ describe('FieldItem', () => { />, ) - // Assert const varElement = screen.getByTitle(longVariable) expect(varElement).toHaveClass('truncate') }) it('should handle very long label names with truncation', () => { - // Arrange const longLabel = 'b'.repeat(200) const payload = createInputVar({ label: longLabel }) - // Act render( <FieldItem payload={payload} @@ -555,19 +472,16 @@ describe('FieldItem', () => { />, ) - // Assert const labelElement = screen.getByTitle(longLabel) expect(labelElement).toHaveClass('truncate') }) it('should handle special characters in variable and label', () => { - // Arrange const payload = createInputVar({ variable: '<test>&"var\'', label: '<label>&"test\'', }) - // Act render( <FieldItem payload={payload} @@ -577,19 +491,16 @@ describe('FieldItem', () => { />, ) - // Assert expect(screen.getByText('<test>&"var\'')).toBeInTheDocument() expect(screen.getByText('<label>&"test\'')).toBeInTheDocument() }) it('should handle unicode characters', () => { - // Arrange const payload = createInputVar({ variable: '揘量_🎉', label: '标筟_😀', }) - // Act render( <FieldItem payload={payload} @@ -599,13 +510,11 @@ describe('FieldItem', () => { />, ) - // Assert expect(screen.getByText('揘量_🎉')).toBeInTheDocument() expect(screen.getByText('标筟_😀')).toBeInTheDocument() }) it('should render different input types correctly', () => { - // Arrange const types = [ PipelineInputVarType.textInput, PipelineInputVarType.paragraph, @@ -619,7 +528,6 @@ describe('FieldItem', () => { types.forEach((type) => { const payload = createInputVar({ type }) - // Act const { unmount } = render( <FieldItem payload={payload} @@ -629,24 +537,18 @@ describe('FieldItem', () => { />, ) - // Assert expect(screen.getByText('test_variable')).toBeInTheDocument() unmount() }) }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { - // Arrange const payload = createInputVar() const onClickEdit = vi.fn() const onRemove = vi.fn() - // Act const { rerender } = render( <FieldItem payload={payload} @@ -656,7 +558,6 @@ describe('FieldItem', () => { />, ) - // Rerender with same props rerender( <FieldItem payload={payload} @@ -666,21 +567,15 @@ describe('FieldItem', () => { />, ) - // Assert - component should still render correctly expect(screen.getByText('test_variable')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Readonly Mode Behavior Tests - // ------------------------------------------------------------------------- describe('Readonly Mode Behavior', () => { it('should not render action buttons in readonly mode even when hovering', () => { - // Arrange mockIsHovering = true const payload = createInputVar() - // Act render( <FieldItem payload={payload} @@ -691,16 +586,13 @@ describe('FieldItem', () => { />, ) - // Assert - no action buttons should be rendered expect(screen.queryAllByRole('button')).toHaveLength(0) }) it('should render type icon and required badge in readonly mode when hovering', () => { - // Arrange mockIsHovering = true const payload = createInputVar({ required: true }) - // Act render( <FieldItem payload={payload} @@ -711,15 +603,12 @@ describe('FieldItem', () => { />, ) - // Assert - required badge should be visible instead of action buttons expect(screen.getByText(/required/i)).toBeInTheDocument() }) it('should apply cursor-default class when readonly', () => { - // Arrange const payload = createInputVar() - // Act const { container } = render( <FieldItem payload={payload} @@ -730,17 +619,14 @@ describe('FieldItem', () => { />, ) - // Assert const fieldItem = container.firstChild as HTMLElement expect(fieldItem.className).toContain('cursor-default') }) it('should apply cursor-all-scroll class when hovering and not readonly', () => { - // Arrange mockIsHovering = true const payload = createInputVar() - // Act const { container } = render( <FieldItem payload={payload} @@ -751,32 +637,22 @@ describe('FieldItem', () => { />, ) - // Assert const fieldItem = container.firstChild as HTMLElement expect(fieldItem.className).toContain('cursor-all-scroll') }) }) }) -// ============================================================================ -// FieldListContainer Component Tests -// ============================================================================ - describe('FieldListContainer', () => { beforeEach(() => { vi.clearAllMocks() mockIsHovering = false }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render sortable container', () => { - // Arrange const inputFields = createInputVarList(2) - // Act render( <FieldListContainer inputFields={inputFields} @@ -786,15 +662,12 @@ describe('FieldListContainer', () => { />, ) - // Assert expect(screen.getByTestId('sortable-container')).toBeInTheDocument() }) it('should render all field items', () => { - // Arrange const inputFields = createInputVarList(3) - // Act render( <FieldListContainer inputFields={inputFields} @@ -804,14 +677,12 @@ describe('FieldListContainer', () => { />, ) - // Assert expect(screen.getByText('var_0')).toBeInTheDocument() expect(screen.getByText('var_1')).toBeInTheDocument() expect(screen.getByText('var_2')).toBeInTheDocument() }) it('should render empty list without errors', () => { - // Act render( <FieldListContainer inputFields={[]} @@ -821,15 +692,12 @@ describe('FieldListContainer', () => { />, ) - // Assert expect(screen.getByTestId('sortable-container')).toBeInTheDocument() }) it('should apply custom className', () => { - // Arrange const inputFields = createInputVarList(1) - // Act render( <FieldListContainer className="custom-class" @@ -840,16 +708,13 @@ describe('FieldListContainer', () => { />, ) - // Assert const container = screen.getByTestId('sortable-container') expect(container.className).toContain('custom-class') }) it('should disable sorting when readonly is true', () => { - // Arrange const inputFields = createInputVarList(2) - // Act render( <FieldListContainer inputFields={inputFields} @@ -860,22 +725,16 @@ describe('FieldListContainer', () => { />, ) - // Assert const container = screen.getByTestId('sortable-container') expect(container.dataset.disabled).toBe('true') }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onListSortChange when items are reordered', () => { - // Arrange const inputFields = createInputVarList(2) const onListSortChange = vi.fn() - // Act render( <FieldListContainer inputFields={inputFields} @@ -886,16 +745,13 @@ describe('FieldListContainer', () => { ) fireEvent.click(screen.getByTestId('trigger-sort')) - // Assert expect(onListSortChange).toHaveBeenCalled() }) it('should not call onListSortChange when list hasnt changed', () => { - // Arrange const inputFields = [createInputVar()] const onListSortChange = vi.fn() - // Act render( <FieldListContainer inputFields={inputFields} @@ -906,16 +762,13 @@ describe('FieldListContainer', () => { ) fireEvent.click(screen.getByTestId('trigger-sort')) - // Assert - with only one item, no reorder happens expect(onListSortChange).not.toHaveBeenCalled() }) it('should not call onListSortChange when disabled', () => { - // Arrange const inputFields = createInputVarList(2) const onListSortChange = vi.fn() - // Act render( <FieldListContainer inputFields={inputFields} @@ -927,16 +780,13 @@ describe('FieldListContainer', () => { ) fireEvent.click(screen.getByTestId('trigger-sort')) - // Assert expect(onListSortChange).not.toHaveBeenCalled() }) it('should not call onListSortChange when list order is unchanged (isEqual check)', () => { - // Arrange - This tests line 42 in field-list-container.tsx const inputFields = createInputVarList(2) const onListSortChange = vi.fn() - // Act render( <FieldListContainer inputFields={inputFields} @@ -945,20 +795,16 @@ describe('FieldListContainer', () => { onEditField={vi.fn()} />, ) - // Trigger same sort - passes same list to setList fireEvent.click(screen.getByTestId('trigger-same-sort')) - // Assert - onListSortChange should NOT be called due to isEqual check expect(onListSortChange).not.toHaveBeenCalled() }) it('should pass onEditField to FieldItem', () => { - // Arrange mockIsHovering = true const inputFields = createInputVarList(1) const onEditField = vi.fn() - // Act render( <FieldListContainer inputFields={inputFields} @@ -970,17 +816,14 @@ describe('FieldListContainer', () => { const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]) // Edit button - // Assert expect(onEditField).toHaveBeenCalledWith('var_0') }) it('should pass onRemoveField to FieldItem', () => { - // Arrange mockIsHovering = true const inputFields = createInputVarList(1) const onRemoveField = vi.fn() - // Act render( <FieldListContainer inputFields={inputFields} @@ -992,24 +835,18 @@ describe('FieldListContainer', () => { const buttons = screen.getAllByRole('button') fireEvent.click(buttons[1]) // Delete button - // Assert expect(onRemoveField).toHaveBeenCalledWith(0) }) }) - // ------------------------------------------------------------------------- - // List Conversion Tests - // ------------------------------------------------------------------------- describe('List Conversion', () => { it('should convert InputVar[] to SortableItem[]', () => { - // Arrange const inputFields = [ createInputVar({ variable: 'var1' }), createInputVar({ variable: 'var2' }), ] const onListSortChange = vi.fn() - // Act render( <FieldListContainer inputFields={inputFields} @@ -1020,7 +857,6 @@ describe('FieldListContainer', () => { ) fireEvent.click(screen.getByTestId('trigger-sort')) - // Assert - onListSortChange should receive SortableItem[] expect(onListSortChange).toHaveBeenCalled() const calledWith = onListSortChange.mock.calls[0][0] expect(calledWith[0]).toHaveProperty('id') @@ -1029,16 +865,11 @@ describe('FieldListContainer', () => { }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should memoize list transformation', () => { - // Arrange const inputFields = createInputVarList(2) const onListSortChange = vi.fn() - // Act const { rerender } = render( <FieldListContainer inputFields={inputFields} @@ -1057,15 +888,12 @@ describe('FieldListContainer', () => { />, ) - // Assert - component should still render correctly expect(screen.getByText('var_0')).toBeInTheDocument() }) it('should be memoized with React.memo', () => { - // Arrange const inputFields = createInputVarList(1) - // Act const { rerender } = render( <FieldListContainer inputFields={inputFields} @@ -1075,7 +903,6 @@ describe('FieldListContainer', () => { />, ) - // Rerender with same props rerender( <FieldListContainer inputFields={inputFields} @@ -1085,20 +912,14 @@ describe('FieldListContainer', () => { />, ) - // Assert expect(screen.getByText('var_0')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle large list of items', () => { - // Arrange const inputFields = createInputVarList(100) - // Act render( <FieldListContainer inputFields={inputFields} @@ -1108,14 +929,11 @@ describe('FieldListContainer', () => { />, ) - // Assert expect(screen.getByText('var_0')).toBeInTheDocument() expect(screen.getByText('var_99')).toBeInTheDocument() }) it('should throw error when inputFields is undefined', () => { - // This test documents that undefined inputFields will cause an error - // In production, this should be prevented by TypeScript expect(() => render( <FieldListContainer @@ -1130,10 +948,6 @@ describe('FieldListContainer', () => { }) }) -// ============================================================================ -// FieldList Component Tests (Integration) -// ============================================================================ - describe('FieldList', () => { beforeEach(() => { vi.clearAllMocks() @@ -1141,15 +955,10 @@ describe('FieldList', () => { mockIsVarUsedInNodes.mockReturnValue(false) }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render FieldList component', () => { - // Arrange const inputFields = createInputVarList(2) - // Act render( <FieldList nodeId="node-1" @@ -1160,16 +969,13 @@ describe('FieldList', () => { />, ) - // Assert expect(screen.getByText('Label Content')).toBeInTheDocument() expect(screen.getByText('var_0')).toBeInTheDocument() }) it('should render add button', () => { - // Arrange const inputFields = createInputVarList(1) - // Act render( <FieldList nodeId="node-1" @@ -1180,7 +986,6 @@ describe('FieldList', () => { />, ) - // Assert const addButton = screen.getAllByRole('button').find(btn => btn.querySelector('svg'), ) @@ -1188,10 +993,8 @@ describe('FieldList', () => { }) it('should disable add button when readonly', () => { - // Arrange const inputFields = createInputVarList(1) - // Act render( <FieldList nodeId="node-1" @@ -1203,7 +1006,6 @@ describe('FieldList', () => { />, ) - // Assert const addButton = screen.getAllByRole('button').find(btn => btn.querySelector('svg'), ) @@ -1211,10 +1013,8 @@ describe('FieldList', () => { }) it('should apply custom labelClassName', () => { - // Arrange const inputFields = createInputVarList(1) - // Act const { container } = render( <FieldList nodeId="node-1" @@ -1226,21 +1026,15 @@ describe('FieldList', () => { />, ) - // Assert const labelContainer = container.querySelector('.custom-label-class') expect(labelContainer).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should open editor panel when add button is clicked', () => { - // Arrange const inputFields = createInputVarList(1) - // Act render( <FieldList nodeId="node-1" @@ -1256,15 +1050,12 @@ describe('FieldList', () => { if (addButton) fireEvent.click(addButton) - // Assert expect(mockToggleInputFieldEditPanel).toHaveBeenCalled() }) it('should not open editor when readonly and add button clicked', () => { - // Arrange const inputFields = createInputVarList(1) - // Act render( <FieldList nodeId="node-1" @@ -1281,21 +1072,15 @@ describe('FieldList', () => { if (addButton) fireEvent.click(addButton) - // Assert - button is disabled so click shouldnt work expect(mockToggleInputFieldEditPanel).not.toHaveBeenCalled() }) }) - // ------------------------------------------------------------------------- - // Callback Tests - // ------------------------------------------------------------------------- describe('Callback Handling', () => { it('should call handleInputFieldsChange with nodeId when fields change', () => { - // Arrange const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-123" @@ -1305,10 +1090,8 @@ describe('FieldList', () => { allVariableNames={[]} />, ) - // Trigger sort to cause fields change fireEvent.click(screen.getByTestId('trigger-sort')) - // Assert expect(handleInputFieldsChange).toHaveBeenCalledWith( 'node-123', expect.any(Array), @@ -1316,17 +1099,12 @@ describe('FieldList', () => { }) }) - // ------------------------------------------------------------------------- - // Remove Confirmation Tests - // ------------------------------------------------------------------------- describe('Remove Confirmation', () => { it('should show remove confirmation when variable is used in nodes', async () => { - // Arrange mockIsVarUsedInNodes.mockReturnValue(true) mockIsHovering = true const inputFields = createInputVarList(1) - // Act render( <FieldList nodeId="node-1" @@ -1337,26 +1115,21 @@ describe('FieldList', () => { />, ) - // Find all buttons in the sortable container (edit and delete) const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') - // The second button should be the delete button if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) - // Assert await waitFor(() => { expect(screen.getByTestId('remove-var-confirm')).toBeInTheDocument() }) }) it('should hide remove confirmation when cancel is clicked', async () => { - // Arrange mockIsVarUsedInNodes.mockReturnValue(true) mockIsHovering = true const inputFields = createInputVarList(1) - // Act render( <FieldList nodeId="node-1" @@ -1367,7 +1140,6 @@ describe('FieldList', () => { />, ) - // Trigger remove - find delete button in sortable container const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 2) @@ -1377,23 +1149,19 @@ describe('FieldList', () => { expect(screen.getByTestId('remove-var-confirm')).toBeInTheDocument() }) - // Click cancel fireEvent.click(screen.getByTestId('confirm-cancel')) - // Assert await waitFor(() => { expect(screen.queryByTestId('remove-var-confirm')).not.toBeInTheDocument() }) }) it('should remove field and call removeUsedVarInNodes when confirm is clicked', async () => { - // Arrange mockIsVarUsedInNodes.mockReturnValue(true) mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -1404,7 +1172,6 @@ describe('FieldList', () => { />, ) - // Trigger remove - find delete button in sortable container const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 2) @@ -1414,10 +1181,8 @@ describe('FieldList', () => { expect(screen.getByTestId('remove-var-confirm')).toBeInTheDocument() }) - // Click confirm fireEvent.click(screen.getByTestId('confirm-ok')) - // Assert await waitFor(() => { expect(handleInputFieldsChange).toHaveBeenCalled() expect(mockRemoveUsedVarInNodes).toHaveBeenCalled() @@ -1425,13 +1190,11 @@ describe('FieldList', () => { }) it('should remove field directly when variable is not used in nodes', () => { - // Arrange mockIsVarUsedInNodes.mockReturnValue(false) mockIsHovering = true const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -1442,24 +1205,18 @@ describe('FieldList', () => { />, ) - // Find delete button in sortable container const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) - // Assert - should not show confirmation expect(screen.queryByTestId('remove-var-confirm')).not.toBeInTheDocument() expect(handleInputFieldsChange).toHaveBeenCalled() }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty inputFields', () => { - // Act render( <FieldList nodeId="node-1" @@ -1470,12 +1227,10 @@ describe('FieldList', () => { />, ) - // Assert expect(screen.getByTestId('sortable-container')).toBeInTheDocument() }) it('should handle null LabelRightContent', () => { - // Act render( <FieldList nodeId="node-1" @@ -1486,12 +1241,10 @@ describe('FieldList', () => { />, ) - // Assert - should render without errors expect(screen.getByText('var_0')).toBeInTheDocument() }) it('should handle complex LabelRightContent', () => { - // Arrange const complexContent = ( <div data-testid="complex-content"> <span>Part 1</span> @@ -1499,7 +1252,6 @@ describe('FieldList', () => { </div> ) - // Act render( <FieldList nodeId="node-1" @@ -1510,22 +1262,16 @@ describe('FieldList', () => { />, ) - // Assert expect(screen.getByTestId('complex-content')).toBeInTheDocument() expect(screen.getByText('Part 1')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act const { rerender } = render( <FieldList nodeId="node-1" @@ -1546,16 +1292,13 @@ describe('FieldList', () => { />, ) - // Assert expect(screen.getByText('var_0')).toBeInTheDocument() }) it('should maintain stable onInputFieldsChange callback', () => { - // Arrange const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - // Act const { rerender } = render( <FieldList nodeId="node-1" @@ -1580,31 +1323,21 @@ describe('FieldList', () => { fireEvent.click(screen.getByTestId('trigger-sort')) - // Assert expect(handleInputFieldsChange).toHaveBeenCalledTimes(2) }) }) }) -// ============================================================================ -// useFieldList Hook Tests -// ============================================================================ - describe('useFieldList Hook', () => { beforeEach(() => { vi.clearAllMocks() mockIsVarUsedInNodes.mockReturnValue(false) }) - // ------------------------------------------------------------------------- - // Initialization Tests - // ------------------------------------------------------------------------- describe('Initialization', () => { it('should initialize with provided inputFields', () => { - // Arrange const inputFields = createInputVarList(2) - // Act render( <FieldList nodeId="node-1" @@ -1615,13 +1348,11 @@ describe('useFieldList Hook', () => { />, ) - // Assert expect(screen.getByText('var_0')).toBeInTheDocument() expect(screen.getByText('var_1')).toBeInTheDocument() }) it('should initialize with empty inputFields', () => { - // Act render( <FieldList nodeId="node-1" @@ -1632,21 +1363,15 @@ describe('useFieldList Hook', () => { />, ) - // Assert expect(screen.getByTestId('sortable-container')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // handleListSortChange Tests - // ------------------------------------------------------------------------- describe('handleListSortChange', () => { it('should update inputFields and call onInputFieldsChange', () => { - // Arrange const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -1658,7 +1383,6 @@ describe('useFieldList Hook', () => { ) fireEvent.click(screen.getByTestId('trigger-sort')) - // Assert expect(handleInputFieldsChange).toHaveBeenCalledWith( 'node-1', expect.arrayContaining([ @@ -1669,11 +1393,9 @@ describe('useFieldList Hook', () => { }) it('should strip sortable properties from list items', () => { - // Arrange const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -1685,7 +1407,6 @@ describe('useFieldList Hook', () => { ) fireEvent.click(screen.getByTestId('trigger-sort')) - // Assert const calledWith = handleInputFieldsChange.mock.calls[0][1] expect(calledWith[0]).not.toHaveProperty('id') expect(calledWith[0]).not.toHaveProperty('chosen') @@ -1693,17 +1414,12 @@ describe('useFieldList Hook', () => { }) }) - // ------------------------------------------------------------------------- - // handleRemoveField Tests - // ------------------------------------------------------------------------- describe('handleRemoveField', () => { it('should show confirmation when variable is used', async () => { - // Arrange mockIsVarUsedInNodes.mockReturnValue(true) mockIsHovering = true const inputFields = createInputVarList(1) - // Act render( <FieldList nodeId="node-1" @@ -1714,26 +1430,22 @@ describe('useFieldList Hook', () => { />, ) - // Find delete button in sortable container const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) - // Assert await waitFor(() => { expect(screen.getByTestId('remove-var-confirm')).toBeInTheDocument() }) }) it('should remove directly when variable is not used', () => { - // Arrange mockIsVarUsedInNodes.mockReturnValue(false) mockIsHovering = true const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -1744,25 +1456,21 @@ describe('useFieldList Hook', () => { />, ) - // Find delete button in sortable container const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) - // Assert expect(screen.queryByTestId('remove-var-confirm')).not.toBeInTheDocument() expect(handleInputFieldsChange).toHaveBeenCalled() }) it('should not call handleInputFieldsChange immediately when variable is used (lines 70-72)', async () => { - // Arrange - This tests that when variable is used, we show confirmation instead of removing directly mockIsVarUsedInNodes.mockReturnValue(true) mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -1773,13 +1481,11 @@ describe('useFieldList Hook', () => { />, ) - // Find delete button and click it const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) - // Assert - handleInputFieldsChange should NOT be called yet (waiting for confirmation) await waitFor(() => { expect(screen.getByTestId('remove-var-confirm')).toBeInTheDocument() }) @@ -1787,12 +1493,10 @@ describe('useFieldList Hook', () => { }) it('should call isVarUsedInNodes with correct variable selector', async () => { - // Arrange mockIsVarUsedInNodes.mockReturnValue(true) mockIsHovering = true const inputFields = [createInputVar({ variable: 'my_test_var' })] - // Act render( <FieldList nodeId="test-node-123" @@ -1808,18 +1512,15 @@ describe('useFieldList Hook', () => { if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) - // Assert expect(mockIsVarUsedInNodes).toHaveBeenCalledWith(['rag', 'test-node-123', 'my_test_var']) }) it('should handle empty variable name gracefully', async () => { - // Arrange - Tests line 70 with empty variable mockIsVarUsedInNodes.mockReturnValue(false) mockIsHovering = true const inputFields = [createInputVar({ variable: '' })] const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -1835,18 +1536,15 @@ describe('useFieldList Hook', () => { if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) - // Assert - should still work with empty variable expect(mockIsVarUsedInNodes).toHaveBeenCalledWith(['rag', 'node-1', '']) }) it('should set removedVar and removedIndex when showing confirmation (lines 71-73)', async () => { - // Arrange - Tests the setRemovedVar and setRemoveIndex calls in lines 71-73 mockIsVarUsedInNodes.mockReturnValue(true) mockIsHovering = true const inputFields = createInputVarList(3) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -1857,22 +1555,17 @@ describe('useFieldList Hook', () => { />, ) - // Click delete on the SECOND item (index 1) const sortableContainer = screen.getByTestId('sortable-container') const allFieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') - // Each field item has 2 buttons (edit, delete), so index 3 is delete of second item if (allFieldItemButtons.length >= 4) fireEvent.click(allFieldItemButtons[3]) - // Show confirmation await waitFor(() => { expect(screen.getByTestId('remove-var-confirm')).toBeInTheDocument() }) - // Click confirm fireEvent.click(screen.getByTestId('confirm-ok')) - // Assert - should remove the correct item (var_1 at index 1) await waitFor(() => { expect(handleInputFieldsChange).toHaveBeenCalled() }) @@ -1882,15 +1575,10 @@ describe('useFieldList Hook', () => { }) }) - // ------------------------------------------------------------------------- - // handleOpenInputFieldEditor Tests - // ------------------------------------------------------------------------- describe('handleOpenInputFieldEditor', () => { it('should call toggleInputFieldEditPanel with editor props', () => { - // Arrange const inputFields = createInputVarList(1) - // Act render( <FieldList nodeId="node-1" @@ -1906,7 +1594,6 @@ describe('useFieldList Hook', () => { if (addButton) fireEvent.click(addButton) - // Assert expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith( expect.objectContaining({ onClose: expect.any(Function), @@ -1916,11 +1603,9 @@ describe('useFieldList Hook', () => { }) it('should pass initialData when editing existing field', () => { - // Arrange mockIsHovering = true const inputFields = [createInputVar({ variable: 'my_var', label: 'My Label' })] - // Act render( <FieldList nodeId="node-1" @@ -1930,13 +1615,11 @@ describe('useFieldList Hook', () => { allVariableNames={[]} />, ) - // Find edit button in sortable container (first action button) const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) - // Assert expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith( expect.objectContaining({ initialData: expect.objectContaining({ @@ -1948,18 +1631,13 @@ describe('useFieldList Hook', () => { }) }) - // ------------------------------------------------------------------------- - // onRemoveVarConfirm Tests - // ------------------------------------------------------------------------- describe('onRemoveVarConfirm', () => { it('should remove field and call removeUsedVarInNodes', async () => { - // Arrange mockIsVarUsedInNodes.mockReturnValue(true) mockIsHovering = true const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -1970,7 +1648,6 @@ describe('useFieldList Hook', () => { />, ) - // Find delete button in sortable container const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 2) @@ -1982,7 +1659,6 @@ describe('useFieldList Hook', () => { fireEvent.click(screen.getByTestId('confirm-ok')) - // Assert await waitFor(() => { expect(handleInputFieldsChange).toHaveBeenCalled() expect(mockRemoveUsedVarInNodes).toHaveBeenCalled() @@ -1991,10 +1667,6 @@ describe('useFieldList Hook', () => { }) }) -// ============================================================================ -// handleSubmitField Tests (via toggleInputFieldEditPanel mock) -// ============================================================================ - describe('handleSubmitField', () => { beforeEach(() => { vi.clearAllMocks() @@ -2003,11 +1675,9 @@ describe('handleSubmitField', () => { }) it('should add new field when editingFieldIndex is -1', () => { - // Arrange const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2018,19 +1688,15 @@ describe('handleSubmitField', () => { />, ) - // Click add button to open editor fireEvent.click(screen.getByTestId('field-list-add-btn')) - // Get the onSubmit callback that was passed to toggleInputFieldEditPanel expect(mockToggleInputFieldEditPanel).toHaveBeenCalled() const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] expect(editorProps).toHaveProperty('onSubmit') - // Simulate form submission with new field data const newFieldData = createInputVar({ variable: 'new_var', label: 'New Label' }) editorProps.onSubmit(newFieldData) - // Assert expect(handleInputFieldsChange).toHaveBeenCalledWith( 'node-1', expect.arrayContaining([ @@ -2041,12 +1707,10 @@ describe('handleSubmitField', () => { }) it('should update existing field when editingFieldIndex is valid', () => { - // Arrange mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2057,20 +1721,16 @@ describe('handleSubmitField', () => { />, ) - // Click edit button on existing field const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) - // Get the onSubmit callback const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] - // Simulate form submission with updated data const updatedFieldData = createInputVar({ variable: 'var_0', label: 'Updated Label' }) editorProps.onSubmit(updatedFieldData) - // Assert - field should be updated, not added expect(handleInputFieldsChange).toHaveBeenCalledWith( 'node-1', expect.arrayContaining([ @@ -2082,12 +1742,10 @@ describe('handleSubmitField', () => { }) it('should call handleInputVarRename when variable name changes', () => { - // Arrange mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2098,23 +1756,19 @@ describe('handleSubmitField', () => { />, ) - // Click edit button const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) - // Get the onSubmit callback const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] - // Simulate form submission with changed variable name (including moreInfo) const updatedFieldData = createInputVar({ variable: 'new_var_name', label: 'Label 0' }) editorProps.onSubmit(updatedFieldData, { type: 'changeVarName', payload: { beforeKey: 'var_0', afterKey: 'new_var_name' }, }) - // Assert expect(mockHandleInputVarRename).toHaveBeenCalledWith( 'node-1', ['rag', 'node-1', 'var_0'], @@ -2123,12 +1777,10 @@ describe('handleSubmitField', () => { }) it('should not call handleInputVarRename when moreInfo type is not changeVarName', () => { - // Arrange - This tests line 108 branch in hooks.ts mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2139,31 +1791,25 @@ describe('handleSubmitField', () => { />, ) - // Click edit button const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) - // Get the onSubmit callback const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] - // Simulate form submission WITHOUT moreInfo (no variable name change) const updatedFieldData = createInputVar({ variable: 'var_0', label: 'Updated Label' }) editorProps.onSubmit(updatedFieldData) - // Assert - handleInputVarRename should NOT be called expect(mockHandleInputVarRename).not.toHaveBeenCalled() expect(handleInputFieldsChange).toHaveBeenCalled() }) it('should not call handleInputVarRename when moreInfo has different type', () => { - // Arrange - This tests line 108 branch in hooks.ts with different type mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2174,31 +1820,25 @@ describe('handleSubmitField', () => { />, ) - // Click edit button const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) - // Get the onSubmit callback const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] - // Simulate form submission with moreInfo but different type const updatedFieldData = createInputVar({ variable: 'var_0', label: 'Updated Label' }) - editorProps.onSubmit(updatedFieldData, { type: 'otherType' as any }) + editorProps.onSubmit(updatedFieldData, { type: 'otherType' as never }) - // Assert - handleInputVarRename should NOT be called expect(mockHandleInputVarRename).not.toHaveBeenCalled() expect(handleInputFieldsChange).toHaveBeenCalled() }) it('should handle empty beforeKey and afterKey in moreInfo payload', () => { - // Arrange - This tests line 108 with empty keys mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2209,23 +1849,19 @@ describe('handleSubmitField', () => { />, ) - // Click edit button const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) - // Get the onSubmit callback const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] - // Simulate form submission with changeVarName but empty keys const updatedFieldData = createInputVar({ variable: 'new_var' }) editorProps.onSubmit(updatedFieldData, { type: 'changeVarName', payload: { beforeKey: '', afterKey: '' }, }) - // Assert - handleInputVarRename should be called with empty strings expect(mockHandleInputVarRename).toHaveBeenCalledWith( 'node-1', ['rag', 'node-1', ''], @@ -2234,12 +1870,10 @@ describe('handleSubmitField', () => { }) it('should handle undefined payload in moreInfo', () => { - // Arrange - This tests line 108 with undefined payload mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2250,23 +1884,19 @@ describe('handleSubmitField', () => { />, ) - // Click edit button const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) - // Get the onSubmit callback const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] - // Simulate form submission with changeVarName but undefined payload const updatedFieldData = createInputVar({ variable: 'new_var' }) editorProps.onSubmit(updatedFieldData, { type: 'changeVarName', payload: undefined, }) - // Assert - handleInputVarRename should be called with empty strings (fallback) expect(mockHandleInputVarRename).toHaveBeenCalledWith( 'node-1', ['rag', 'node-1', ''], @@ -2275,11 +1905,9 @@ describe('handleSubmitField', () => { }) it('should close editor panel after successful submission', () => { - // Arrange const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2290,26 +1918,20 @@ describe('handleSubmitField', () => { />, ) - // Click add button fireEvent.click(screen.getByTestId('field-list-add-btn')) - // Get the onSubmit callback const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] - // Simulate form submission const newFieldData = createInputVar({ variable: 'new_var' }) editorProps.onSubmit(newFieldData) - // Assert - toggleInputFieldEditPanel should be called with null to close expect(mockToggleInputFieldEditPanel).toHaveBeenCalledTimes(2) expect(mockToggleInputFieldEditPanel).toHaveBeenLastCalledWith(null) }) it('should call onClose when editor is closed manually', () => { - // Arrange const inputFields = createInputVarList(1) - // Act render( <FieldList nodeId="node-1" @@ -2320,25 +1942,17 @@ describe('handleSubmitField', () => { />, ) - // Click add button fireEvent.click(screen.getByTestId('field-list-add-btn')) - // Get the onClose callback const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] expect(editorProps).toHaveProperty('onClose') - // Simulate close editorProps.onClose() - // Assert - toggleInputFieldEditPanel should be called with null expect(mockToggleInputFieldEditPanel).toHaveBeenLastCalledWith(null) }) }) -// ============================================================================ -// Duplicate Variable Name Handling Tests -// ============================================================================ - describe('Duplicate Variable Name Handling', () => { beforeEach(() => { vi.clearAllMocks() @@ -2347,12 +1961,10 @@ describe('Duplicate Variable Name Handling', () => { }) it('should not add field if variable name is duplicate', async () => { - // Arrange const Toast = await import('@/app/components/base/toast') const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2363,31 +1975,24 @@ describe('Duplicate Variable Name Handling', () => { />, ) - // Click add button fireEvent.click(screen.getByTestId('field-list-add-btn')) - // Get the onSubmit callback const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] - // Try to submit with a duplicate variable name const duplicateFieldData = createInputVar({ variable: 'existing_var' }) editorProps.onSubmit(duplicateFieldData) - // Assert - handleInputFieldsChange should NOT be called expect(handleInputFieldsChange).not.toHaveBeenCalled() - // Toast should be shown expect(Toast.default.notify).toHaveBeenCalledWith( expect.objectContaining({ type: 'error' }), ) }) it('should allow updating field to same variable name', () => { - // Arrange mockIsHovering = true const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2398,35 +2003,25 @@ describe('Duplicate Variable Name Handling', () => { />, ) - // Click edit button on first field const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) - // Get the onSubmit callback const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0] - // Submit with same variable name (just updating label) const updatedFieldData = createInputVar({ variable: 'var_0', label: 'New Label' }) editorProps.onSubmit(updatedFieldData) - // Assert - should allow update with same variable name expect(handleInputFieldsChange).toHaveBeenCalled() }) }) -// ============================================================================ -// SortableItem Type Tests -// ============================================================================ - describe('SortableItem Type', () => { it('should have correct structure', () => { - // Arrange const inputVar = createInputVar() const sortableItem = createSortableItem(inputVar) - // Assert expect(sortableItem.id).toBe(inputVar.variable) expect(sortableItem.chosen).toBe(false) expect(sortableItem.selected).toBe(false) @@ -2436,23 +2031,17 @@ describe('SortableItem Type', () => { }) it('should allow overriding sortable properties', () => { - // Arrange const inputVar = createInputVar() const sortableItem = createSortableItem(inputVar, { chosen: true, selected: true, }) - // Assert expect(sortableItem.chosen).toBe(true) expect(sortableItem.selected).toBe(true) }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('Integration Tests', () => { beforeEach(() => { vi.clearAllMocks() @@ -2462,12 +2051,10 @@ describe('Integration Tests', () => { describe('Complete Workflow', () => { it('should handle add -> edit -> remove workflow', async () => { - // Arrange mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - // Act - Render render( <FieldList nodeId="node-1" @@ -2478,11 +2065,9 @@ describe('Integration Tests', () => { />, ) - // Step 1: Click add button (in header, outside sortable container) fireEvent.click(screen.getByTestId('field-list-add-btn')) expect(mockToggleInputFieldEditPanel).toHaveBeenCalled() - // Step 2: Edit on existing field const sortableContainer = screen.getByTestId('sortable-container') const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') if (fieldItemButtons.length >= 1) { @@ -2490,7 +2075,6 @@ describe('Integration Tests', () => { expect(mockToggleInputFieldEditPanel).toHaveBeenCalledTimes(2) } - // Step 3: Remove field if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -2498,11 +2082,9 @@ describe('Integration Tests', () => { }) it('should handle sort operation correctly', () => { - // Arrange const inputFields = createInputVarList(3) const handleInputFieldsChange = vi.fn() - // Act render( <FieldList nodeId="node-1" @@ -2515,13 +2097,11 @@ describe('Integration Tests', () => { fireEvent.click(screen.getByTestId('trigger-sort')) - // Assert expect(handleInputFieldsChange).toHaveBeenCalledWith( 'node-1', expect.any(Array), ) const newOrder = handleInputFieldsChange.mock.calls[0][1] - // First two should be swapped expect(newOrder[0].variable).toBe('var_1') expect(newOrder[1].variable).toBe('var_0') }) @@ -2529,10 +2109,8 @@ describe('Integration Tests', () => { describe('Props Propagation', () => { it('should propagate readonly prop through all components', () => { - // Arrange const inputFields = createInputVarList(2) - // Act render( <FieldList nodeId="node-1" @@ -2544,7 +2122,6 @@ describe('Integration Tests', () => { />, ) - // Assert const addButton = screen.getAllByRole('button').find(btn => btn.querySelector('svg'), ) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/rag-pipeline/components/panel/input-field/label-right-content/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx index 71be12bb8d..ba9390a028 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx @@ -2,17 +2,9 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { BlockEnum } from '@/app/components/workflow/types' -import Datasource from './datasource' -import GlobalInputs from './global-inputs' +import Datasource from '../datasource' +import GlobalInputs from '../global-inputs' -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock BlockIcon vi.mock('@/app/components/workflow/block-icon', () => ({ default: ({ type, toolIcon, className }: { type: BlockEnum, toolIcon?: string, className?: string }) => ( <div @@ -24,12 +16,10 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ ), })) -// Mock useToolIcon vi.mock('@/app/components/workflow/hooks', () => ({ useToolIcon: (nodeData: DataSourceNodeType) => nodeData.provider_name || 'default-icon', })) -// Mock Tooltip vi.mock('@/app/components/base/tooltip', () => ({ default: ({ popupContent, popupClassName }: { popupContent: string, popupClassName?: string }) => ( <div data-testid="tooltip" data-content={popupContent} className={popupClassName} /> @@ -132,7 +122,6 @@ describe('Datasource', () => { render(<Datasource nodeData={nodeData} />) - // Should still render without the title text expect(screen.getByTestId('block-icon')).toBeInTheDocument() }) @@ -160,13 +149,13 @@ describe('GlobalInputs', () => { it('should render without crashing', () => { render(<GlobalInputs />) - expect(screen.getByText('inputFieldPanel.globalInputs.title')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument() }) it('should render title with correct translation key', () => { render(<GlobalInputs />) - expect(screen.getByText('inputFieldPanel.globalInputs.title')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument() }) it('should render tooltip component', () => { @@ -179,7 +168,7 @@ describe('GlobalInputs', () => { render(<GlobalInputs />) const tooltip = screen.getByTestId('tooltip') - expect(tooltip).toHaveAttribute('data-content', 'inputFieldPanel.globalInputs.tooltip') + expect(tooltip).toHaveAttribute('data-content', 'datasetPipeline.inputFieldPanel.globalInputs.tooltip') }) it('should have correct tooltip className', () => { @@ -199,7 +188,7 @@ describe('GlobalInputs', () => { it('should have correct title styling', () => { render(<GlobalInputs />) - const titleElement = screen.getByText('inputFieldPanel.globalInputs.title') + const titleElement = screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title') expect(titleElement).toHaveClass('system-sm-semibold-uppercase', 'text-text-secondary') }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx similarity index 75% rename from web/app/components/rag-pipeline/components/panel/input-field/preview/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx index f86297ccb5..6284d3045b 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx @@ -1,29 +1,23 @@ -import type { Datasource, DataSourceOption } from '../../test-run/types' +import type { Datasource, DataSourceOption } from '../../../test-run/types' import type { RAGPipelineVariable, RAGPipelineVariables } from '@/models/pipeline' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { PipelineInputVarType } from '@/models/pipeline' -import DataSource from './data-source' -import Form from './form' -import PreviewPanel from './index' -import ProcessDocuments from './process-documents' +import DataSource from '../data-source' +import Form from '../form' +import PreviewPanel from '../index' +import ProcessDocuments from '../process-documents' -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock useFloatingRight hook const mockUseFloatingRight = vi.fn(() => ({ floatingRight: false, floatingRightWidth: 480, })) -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useFloatingRight: () => mockUseFloatingRight(), })) -// Mock useInputFieldPanel hook const mockToggleInputFieldPreviewPanel = vi.fn() vi.mock('@/app/components/rag-pipeline/hooks', () => ({ useInputFieldPanel: () => ({ @@ -35,7 +29,6 @@ vi.mock('@/app/components/rag-pipeline/hooks', () => ({ }), })) -// Track mock state for workflow store let mockPipelineId: string | null = 'test-pipeline-id' vi.mock('@/app/components/workflow/store', () => ({ @@ -56,17 +49,14 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock reactflow store vi.mock('reactflow', () => ({ useStore: () => undefined, })) -// Mock zustand shallow vi.mock('zustand/react/shallow', () => ({ useShallow: (fn: unknown) => fn, })) -// Track mock data for API hooks let mockPreProcessingParamsData: { variables: RAGPipelineVariables } | undefined let mockProcessingParamsData: { variables: RAGPipelineVariables } | undefined @@ -83,10 +73,9 @@ vi.mock('@/service/use-pipeline', () => ({ }), })) -// Track mock datasource options let mockDatasourceOptions: DataSourceOption[] = [] -vi.mock('../../test-run/preparation/data-source-options', () => ({ +vi.mock('../../../test-run/preparation/data-source-options', () => ({ default: ({ onSelect, dataSourceNodeId, @@ -113,13 +102,11 @@ vi.mock('../../test-run/preparation/data-source-options', () => ({ ), })) -// Helper function to convert option string to option object const mapOptionToObject = (option: string) => ({ label: option, value: option, }) -// Mock form-related hooks vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ useInitialData: (variables: RAGPipelineVariables) => { return React.useMemo(() => { @@ -150,7 +137,6 @@ vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ }, })) -// Mock useAppForm hook vi.mock('@/app/components/base/form', () => ({ useAppForm: ({ defaultValues }: { defaultValues: Record<string, unknown> }) => ({ handleSubmit: vi.fn(), @@ -163,7 +149,6 @@ vi.mock('@/app/components/base/form', () => ({ }), })) -// Mock BaseField component vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ default: ({ config }: { initialData: Record<string, unknown>, config: { variable: string, label: string } }) => { const FieldComponent = ({ form }: { form: unknown }) => ( @@ -177,10 +162,6 @@ vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ }, })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createRAGPipelineVariable = ( overrides?: Partial<RAGPipelineVariable>, ): RAGPipelineVariable => ({ @@ -209,10 +190,6 @@ const createDatasourceOption = ( ...overrides, }) -// ============================================================================ -// Test Wrapper Component -// ============================================================================ - const createTestQueryClient = () => new QueryClient({ defaultOptions: { @@ -234,10 +211,6 @@ const renderWithProviders = (ui: React.ReactElement) => { return render(ui, { wrapper: TestWrapper }) } -// ============================================================================ -// PreviewPanel Component Tests -// ============================================================================ - describe('PreviewPanel', () => { beforeEach(() => { vi.clearAllMocks() @@ -251,170 +224,126 @@ describe('PreviewPanel', () => { mockDatasourceOptions = [] }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render preview panel without crashing', () => { - // Act renderWithProviders(<PreviewPanel />) - // Assert expect( screen.getByText('datasetPipeline.operations.preview'), ).toBeInTheDocument() }) it('should render preview badge', () => { - // Act renderWithProviders(<PreviewPanel />) - // Assert const badge = screen.getByText('datasetPipeline.operations.preview') expect(badge).toBeInTheDocument() }) it('should render close button', () => { - // Act renderWithProviders(<PreviewPanel />) - // Assert const closeButton = screen.getByRole('button') expect(closeButton).toBeInTheDocument() }) it('should render DataSource component', () => { - // Act renderWithProviders(<PreviewPanel />) - // Assert expect(screen.getByTestId('data-source-options')).toBeInTheDocument() }) it('should render ProcessDocuments component', () => { - // Act renderWithProviders(<PreviewPanel />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), ).toBeInTheDocument() }) it('should render divider between sections', () => { - // Act const { container } = renderWithProviders(<PreviewPanel />) - // Assert const divider = container.querySelector('.bg-divider-subtle') expect(divider).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // State Management Tests - // ------------------------------------------------------------------------- describe('State Management', () => { it('should initialize with empty datasource state', () => { - // Act renderWithProviders(<PreviewPanel />) - // Assert expect(screen.getByTestId('current-node-id').textContent).toBe('') }) it('should update datasource state when DataSource selects', () => { - // Arrange mockDatasourceOptions = [ createDatasourceOption({ value: 'node-1', label: 'Node 1' }), ] - // Act renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByTestId('option-node-1')) - // Assert expect(screen.getByTestId('current-node-id').textContent).toBe('node-1') }) it('should pass datasource nodeId to ProcessDocuments', () => { - // Arrange mockDatasourceOptions = [ createDatasourceOption({ value: 'test-node', label: 'Test Node' }), ] - // Act renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByTestId('option-test-node')) - // Assert - ProcessDocuments receives the nodeId expect(screen.getByTestId('current-node-id').textContent).toBe('test-node') }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call toggleInputFieldPreviewPanel when close button clicked', () => { - // Act renderWithProviders(<PreviewPanel />) const closeButton = screen.getByRole('button') fireEvent.click(closeButton) - // Assert expect(mockToggleInputFieldPreviewPanel).toHaveBeenCalledTimes(1) }) it('should handle multiple close button clicks', () => { - // Act renderWithProviders(<PreviewPanel />) const closeButton = screen.getByRole('button') fireEvent.click(closeButton) fireEvent.click(closeButton) fireEvent.click(closeButton) - // Assert expect(mockToggleInputFieldPreviewPanel).toHaveBeenCalledTimes(3) }) it('should handle datasource selection changes', () => { - // Arrange mockDatasourceOptions = [ createDatasourceOption({ value: 'node-1', label: 'Node 1' }), createDatasourceOption({ value: 'node-2', label: 'Node 2' }), ] - // Act renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByTestId('option-node-1')) - // Assert expect(screen.getByTestId('current-node-id').textContent).toBe('node-1') - // Act - Change selection fireEvent.click(screen.getByTestId('option-node-2')) - // Assert expect(screen.getByTestId('current-node-id').textContent).toBe('node-2') }) }) - // ------------------------------------------------------------------------- - // Floating Right Behavior Tests - // ------------------------------------------------------------------------- describe('Floating Right Behavior', () => { it('should apply floating right styles when floatingRight is true', () => { - // Arrange mockUseFloatingRight.mockReturnValue({ floatingRight: true, floatingRightWidth: 400, }) - // Act const { container } = renderWithProviders(<PreviewPanel />) - // Assert const panel = container.firstChild as HTMLElement expect(panel.className).toContain('absolute') expect(panel.className).toContain('right-0') @@ -422,43 +351,33 @@ describe('PreviewPanel', () => { }) it('should not apply floating right styles when floatingRight is false', () => { - // Arrange mockUseFloatingRight.mockReturnValue({ floatingRight: false, floatingRightWidth: 480, }) - // Act const { container } = renderWithProviders(<PreviewPanel />) - // Assert const panel = container.firstChild as HTMLElement expect(panel.className).not.toContain('absolute') expect(panel.style.width).toBe('480px') }) it('should update width when floatingRightWidth changes', () => { - // Arrange mockUseFloatingRight.mockReturnValue({ floatingRight: false, floatingRightWidth: 600, }) - // Act const { container } = renderWithProviders(<PreviewPanel />) - // Assert const panel = container.firstChild as HTMLElement expect(panel.style.width).toBe('600px') }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should maintain stable handleClosePreviewPanel callback', () => { - // Act const { rerender } = renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByRole('button')) @@ -469,51 +388,37 @@ describe('PreviewPanel', () => { ) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockToggleInputFieldPreviewPanel).toHaveBeenCalledTimes(2) }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty datasource options', () => { - // Arrange mockDatasourceOptions = [] - // Act renderWithProviders(<PreviewPanel />) - // Assert expect(screen.getByTestId('data-source-options')).toBeInTheDocument() expect(screen.getByTestId('current-node-id').textContent).toBe('') }) it('should handle rapid datasource selections', () => { - // Arrange mockDatasourceOptions = [ createDatasourceOption({ value: 'node-1', label: 'Node 1' }), createDatasourceOption({ value: 'node-2', label: 'Node 2' }), createDatasourceOption({ value: 'node-3', label: 'Node 3' }), ] - // Act renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByTestId('option-node-1')) fireEvent.click(screen.getByTestId('option-node-2')) fireEvent.click(screen.getByTestId('option-node-3')) - // Assert - Final selection should be node-3 expect(screen.getByTestId('current-node-id').textContent).toBe('node-3') }) }) }) -// ============================================================================ -// DataSource Component Tests -// ============================================================================ - describe('DataSource', () => { beforeEach(() => { vi.clearAllMocks() @@ -522,164 +427,123 @@ describe('DataSource', () => { mockDatasourceOptions = [] }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render step one title', () => { - // Arrange const onSelect = vi.fn() - // Act renderWithProviders( <DataSource onSelect={onSelect} dataSourceNodeId="" />, ) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle'), ).toBeInTheDocument() }) it('should render DataSourceOptions component', () => { - // Arrange const onSelect = vi.fn() - // Act renderWithProviders( <DataSource onSelect={onSelect} dataSourceNodeId="" />, ) - // Assert expect(screen.getByTestId('data-source-options')).toBeInTheDocument() }) it('should pass dataSourceNodeId to DataSourceOptions', () => { - // Arrange const onSelect = vi.fn() - // Act renderWithProviders( <DataSource onSelect={onSelect} dataSourceNodeId="test-node-id" />, ) - // Assert expect(screen.getByTestId('current-node-id').textContent).toBe( 'test-node-id', ) }) }) - // ------------------------------------------------------------------------- - // Props Variations Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should handle empty dataSourceNodeId', () => { - // Arrange const onSelect = vi.fn() - // Act renderWithProviders(<DataSource onSelect={onSelect} dataSourceNodeId="" />) - // Assert expect(screen.getByTestId('current-node-id').textContent).toBe('') }) it('should handle different dataSourceNodeId values', () => { - // Arrange const onSelect = vi.fn() - // Act const { rerender } = renderWithProviders( <DataSource onSelect={onSelect} dataSourceNodeId="node-1" />, ) - // Assert expect(screen.getByTestId('current-node-id').textContent).toBe('node-1') - // Act - Change nodeId rerender( <TestWrapper> <DataSource onSelect={onSelect} dataSourceNodeId="node-2" /> </TestWrapper>, ) - // Assert expect(screen.getByTestId('current-node-id').textContent).toBe('node-2') }) }) - // ------------------------------------------------------------------------- - // API Integration Tests - // ------------------------------------------------------------------------- describe('API Integration', () => { it('should fetch pre-processing params when pipelineId and nodeId are present', async () => { - // Arrange const onSelect = vi.fn() mockPreProcessingParamsData = { variables: [createRAGPipelineVariable()], } - // Act renderWithProviders( <DataSource onSelect={onSelect} dataSourceNodeId="test-node" />, ) - // Assert - Form should render with fetched variables await waitFor(() => { expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() }) }) it('should not render form fields when params data is empty', () => { - // Arrange const onSelect = vi.fn() mockPreProcessingParamsData = { variables: [] } - // Act renderWithProviders( <DataSource onSelect={onSelect} dataSourceNodeId="test-node" />, ) - // Assert expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() }) it('should handle undefined params data', () => { - // Arrange const onSelect = vi.fn() mockPreProcessingParamsData = undefined - // Act renderWithProviders( <DataSource onSelect={onSelect} dataSourceNodeId="" />, ) - // Assert - Should render without errors expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle'), ).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onSelect when datasource option is clicked', () => { - // Arrange const onSelect = vi.fn() mockDatasourceOptions = [ createDatasourceOption({ value: 'selected-node', label: 'Selected' }), ] - // Act renderWithProviders( <DataSource onSelect={onSelect} dataSourceNodeId="" />, ) fireEvent.click(screen.getByTestId('option-selected-node')) - // Assert expect(onSelect).toHaveBeenCalledTimes(1) expect(onSelect).toHaveBeenCalledWith( expect.objectContaining({ @@ -689,58 +553,43 @@ describe('DataSource', () => { }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized (React.memo)', () => { - // Arrange const onSelect = vi.fn() - // Act const { rerender } = renderWithProviders( <DataSource onSelect={onSelect} dataSourceNodeId="node-1" />, ) - // Rerender with same props rerender( <TestWrapper> <DataSource onSelect={onSelect} dataSourceNodeId="node-1" /> </TestWrapper>, ) - // Assert - Component should not cause additional renders expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle'), ).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle null pipelineId', () => { - // Arrange const onSelect = vi.fn() mockPipelineId = null - // Act renderWithProviders( <DataSource onSelect={onSelect} dataSourceNodeId="test-node" />, ) - // Assert - Should render without errors expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle'), ).toBeInTheDocument() }) it('should handle special characters in dataSourceNodeId', () => { - // Arrange const onSelect = vi.fn() - // Act renderWithProviders( <DataSource onSelect={onSelect} @@ -748,7 +597,6 @@ describe('DataSource', () => { />, ) - // Assert expect(screen.getByTestId('current-node-id').textContent).toBe( 'node-with-special-chars_123', ) @@ -756,10 +604,6 @@ describe('DataSource', () => { }) }) -// ============================================================================ -// ProcessDocuments Component Tests -// ============================================================================ - describe('ProcessDocuments', () => { beforeEach(() => { vi.clearAllMocks() @@ -767,81 +611,60 @@ describe('ProcessDocuments', () => { mockProcessingParamsData = undefined }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render step two title', () => { - // Act renderWithProviders(<ProcessDocuments dataSourceNodeId="" />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), ).toBeInTheDocument() }) it('should render Form component', () => { - // Arrange mockProcessingParamsData = { variables: [createRAGPipelineVariable({ variable: 'process_var' })], } - // Act renderWithProviders(<ProcessDocuments dataSourceNodeId="test-node" />) - // Assert - Form should be rendered expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), ).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Props Variations Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should handle empty dataSourceNodeId', () => { - // Act renderWithProviders(<ProcessDocuments dataSourceNodeId="" />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), ).toBeInTheDocument() }) it('should handle different dataSourceNodeId values', () => { - // Act const { rerender } = renderWithProviders( <ProcessDocuments dataSourceNodeId="node-1" />, ) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), ).toBeInTheDocument() - // Act - Change nodeId rerender( <TestWrapper> <ProcessDocuments dataSourceNodeId="node-2" /> </TestWrapper>, ) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), ).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // API Integration Tests - // ------------------------------------------------------------------------- describe('API Integration', () => { it('should fetch processing params when pipelineId and nodeId are present', async () => { - // Arrange mockProcessingParamsData = { variables: [ createRAGPipelineVariable({ @@ -851,41 +674,32 @@ describe('ProcessDocuments', () => { ], } - // Act renderWithProviders(<ProcessDocuments dataSourceNodeId="test-node" />) - // Assert await waitFor(() => { expect(screen.getByTestId('field-chunk_size')).toBeInTheDocument() }) }) it('should not render form fields when params data is empty', () => { - // Arrange mockProcessingParamsData = { variables: [] } - // Act renderWithProviders(<ProcessDocuments dataSourceNodeId="test-node" />) - // Assert expect(screen.queryByTestId('field-chunk_size')).not.toBeInTheDocument() }) it('should handle undefined params data', () => { - // Arrange mockProcessingParamsData = undefined - // Act renderWithProviders(<ProcessDocuments dataSourceNodeId="" />) - // Assert - Should render without errors expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), ).toBeInTheDocument() }) it('should render multiple form fields from params', async () => { - // Arrange mockProcessingParamsData = { variables: [ createRAGPipelineVariable({ @@ -899,10 +713,8 @@ describe('ProcessDocuments', () => { ], } - // Act renderWithProviders(<ProcessDocuments dataSourceNodeId="test-node" />) - // Assert await waitFor(() => { expect(screen.getByTestId('field-var1')).toBeInTheDocument() expect(screen.getByTestId('field-var2')).toBeInTheDocument() @@ -910,55 +722,40 @@ describe('ProcessDocuments', () => { }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized (React.memo)', () => { - // Act const { rerender } = renderWithProviders( <ProcessDocuments dataSourceNodeId="node-1" />, ) - // Rerender with same props rerender( <TestWrapper> <ProcessDocuments dataSourceNodeId="node-1" /> </TestWrapper>, ) - // Assert - Component should render without issues expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), ).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle null pipelineId', () => { - // Arrange mockPipelineId = null - // Act renderWithProviders(<ProcessDocuments dataSourceNodeId="test-node" />) - // Assert - Should render without errors expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), ).toBeInTheDocument() }) it('should handle very long dataSourceNodeId', () => { - // Arrange const longNodeId = 'a'.repeat(100) - // Act renderWithProviders(<ProcessDocuments dataSourceNodeId={longNodeId} />) - // Assert expect( screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle'), ).toBeInTheDocument() @@ -966,57 +763,39 @@ describe('ProcessDocuments', () => { }) }) -// ============================================================================ -// Form Component Tests -// ============================================================================ - describe('Form', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render form element', () => { - // Act const { container } = renderWithProviders(<Form variables={[]} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) it('should render form fields for each variable', () => { - // Arrange const variables = [ createRAGPipelineVariable({ variable: 'field1', label: 'Field 1' }), createRAGPipelineVariable({ variable: 'field2', label: 'Field 2' }), ] - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('field-field1')).toBeInTheDocument() expect(screen.getByTestId('field-field2')).toBeInTheDocument() }) it('should render no fields when variables is empty', () => { - // Act renderWithProviders(<Form variables={[]} />) - // Assert expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Props Variations Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should handle different variable types', () => { - // Arrange const variables = [ createRAGPipelineVariable({ variable: 'text_var', @@ -1033,17 +812,14 @@ describe('Form', () => { }), ] - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('field-text_var')).toBeInTheDocument() expect(screen.getByTestId('field-number_var')).toBeInTheDocument() expect(screen.getByTestId('field-select_var')).toBeInTheDocument() }) it('should handle variables with default values', () => { - // Arrange const variables = [ createRAGPipelineVariable({ variable: 'with_default', @@ -1051,15 +827,12 @@ describe('Form', () => { }), ] - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('field-with_default')).toBeInTheDocument() }) it('should handle variables with all optional fields', () => { - // Arrange const variables = [ createRAGPipelineVariable({ variable: 'full_var', @@ -1073,59 +846,42 @@ describe('Form', () => { }), ] - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('field-full_var')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Form Behavior Tests - // ------------------------------------------------------------------------- describe('Form Behavior', () => { it('should prevent default form submission', () => { - // Arrange const variables = [createRAGPipelineVariable()] const preventDefaultMock = vi.fn() - // Act const { container } = renderWithProviders(<Form variables={variables} />) const form = container.querySelector('form')! - // Create and dispatch submit event const submitEvent = new Event('submit', { bubbles: true, cancelable: true }) Object.defineProperty(submitEvent, 'preventDefault', { value: preventDefaultMock, }) form.dispatchEvent(submitEvent) - // Assert - Form should prevent default submission expect(preventDefaultMock).toHaveBeenCalled() }) it('should pass form to each field component', () => { - // Arrange const variables = [createRAGPipelineVariable({ variable: 'test_var' })] - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('form-ref').textContent).toBe('has-form') }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should memoize initialData when variables do not change', () => { - // Arrange const variables = [createRAGPipelineVariable()] - // Act const { rerender } = renderWithProviders(<Form variables={variables} />) rerender( <TestWrapper> @@ -1133,72 +889,55 @@ describe('Form', () => { </TestWrapper>, ) - // Assert - Component should render without issues expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() }) it('should memoize configurations when variables do not change', () => { - // Arrange const variables = [ createRAGPipelineVariable({ variable: 'var1' }), createRAGPipelineVariable({ variable: 'var2' }), ] - // Act const { rerender } = renderWithProviders(<Form variables={variables} />) - // Rerender with same variables reference rerender( <TestWrapper> <Form variables={variables} /> </TestWrapper>, ) - // Assert expect(screen.getByTestId('field-var1')).toBeInTheDocument() expect(screen.getByTestId('field-var2')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty variables array', () => { - // Act const { container } = renderWithProviders(<Form variables={[]} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument() }) it('should handle single variable', () => { - // Arrange const variables = [createRAGPipelineVariable({ variable: 'single' })] - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('field-single')).toBeInTheDocument() }) it('should handle many variables', () => { - // Arrange const variables = Array.from({ length: 20 }, (_, i) => createRAGPipelineVariable({ variable: `var_${i}`, label: `Var ${i}` })) - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('field-var_0')).toBeInTheDocument() expect(screen.getByTestId('field-var_19')).toBeInTheDocument() }) it('should handle variables with special characters in names', () => { - // Arrange const variables = [ createRAGPipelineVariable({ variable: 'var_with_underscore', @@ -1206,15 +945,12 @@ describe('Form', () => { }), ] - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('field-var_with_underscore')).toBeInTheDocument() }) it('should handle variables with unicode labels', () => { - // Arrange const variables = [ createRAGPipelineVariable({ variable: 'unicode_var', @@ -1223,16 +959,13 @@ describe('Form', () => { }), ] - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('field-unicode_var')).toBeInTheDocument() expect(screen.getByText('䞭文标筟 🎉')).toBeInTheDocument() }) it('should handle variables with empty string default values', () => { - // Arrange const variables = [ createRAGPipelineVariable({ variable: 'empty_default', @@ -1240,15 +973,12 @@ describe('Form', () => { }), ] - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('field-empty_default')).toBeInTheDocument() }) it('should handle variables with zero max_length', () => { - // Arrange const variables = [ createRAGPipelineVariable({ variable: 'zero_length', @@ -1256,19 +986,13 @@ describe('Form', () => { }), ] - // Act renderWithProviders(<Form variables={variables} />) - // Assert expect(screen.getByTestId('field-zero_length')).toBeInTheDocument() }) }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('Preview Panel Integration', () => { beforeEach(() => { vi.clearAllMocks() @@ -1282,12 +1006,8 @@ describe('Preview Panel Integration', () => { mockDatasourceOptions = [] }) - // ------------------------------------------------------------------------- - // End-to-End Flow Tests - // ------------------------------------------------------------------------- describe('End-to-End Flow', () => { it('should complete full preview flow: select datasource -> show forms', async () => { - // Arrange mockDatasourceOptions = [ createDatasourceOption({ value: 'node-1', label: 'Local File' }), ] @@ -1308,13 +1028,10 @@ describe('Preview Panel Integration', () => { ], } - // Act renderWithProviders(<PreviewPanel />) - // Select datasource fireEvent.click(screen.getByTestId('option-node-1')) - // Assert - Both forms should show their fields await waitFor(() => { expect(screen.getByTestId('field-source_var')).toBeInTheDocument() expect(screen.getByTestId('field-process_var')).toBeInTheDocument() @@ -1322,7 +1039,6 @@ describe('Preview Panel Integration', () => { }) it('should update both forms when datasource changes', async () => { - // Arrange mockDatasourceOptions = [ createDatasourceOption({ value: 'node-1', label: 'Node 1' }), createDatasourceOption({ value: 'node-2', label: 'Node 2' }), @@ -1334,75 +1050,56 @@ describe('Preview Panel Integration', () => { variables: [createRAGPipelineVariable({ variable: 'proc_var' })], } - // Act renderWithProviders(<PreviewPanel />) - // Select first datasource fireEvent.click(screen.getByTestId('option-node-1')) - // Assert initial selection await waitFor(() => { expect(screen.getByTestId('current-node-id').textContent).toBe('node-1') }) - // Select second datasource fireEvent.click(screen.getByTestId('option-node-2')) - // Assert updated selection await waitFor(() => { expect(screen.getByTestId('current-node-id').textContent).toBe('node-2') }) }) }) - // ------------------------------------------------------------------------- - // Component Communication Tests - // ------------------------------------------------------------------------- describe('Component Communication', () => { it('should pass correct nodeId from PreviewPanel to child components', () => { - // Arrange mockDatasourceOptions = [ createDatasourceOption({ value: 'communicated-node', label: 'Node' }), ] - // Act renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByTestId('option-communicated-node')) - // Assert expect(screen.getByTestId('current-node-id').textContent).toBe( 'communicated-node', ) }) }) - // ------------------------------------------------------------------------- - // State Persistence Tests - // ------------------------------------------------------------------------- describe('State Persistence', () => { it('should maintain datasource selection within same render cycle', () => { - // Arrange mockDatasourceOptions = [ createDatasourceOption({ value: 'persistent-node', label: 'Persistent' }), createDatasourceOption({ value: 'other-node', label: 'Other' }), ] - // Act renderWithProviders(<PreviewPanel />) fireEvent.click(screen.getByTestId('option-persistent-node')) - // Assert - Selection should be maintained expect(screen.getByTestId('current-node-id').textContent).toBe( 'persistent-node', ) - // Change selection and verify state updates correctly fireEvent.click(screen.getByTestId('option-other-node')) expect(screen.getByTestId('current-node-id').textContent).toBe( 'other-node', ) - // Go back to original and verify fireEvent.click(screen.getByTestId('option-persistent-node')) expect(screen.getByTestId('current-node-id').textContent).toBe( 'persistent-node', diff --git a/web/app/components/rag-pipeline/components/panel/test-run/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/index.spec.tsx similarity index 85% rename from web/app/components/rag-pipeline/components/panel/test-run/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/test-run/__tests__/index.spec.tsx index 7ead398ac1..5acea3733c 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/index.spec.tsx @@ -3,15 +3,9 @@ import type { WorkflowRunningData } from '@/app/components/workflow/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { ChunkingMode } from '@/models/datasets' -import Header from './header' -// Import components after mocks -import TestRunPanel from './index' +import Header from '../header' +import TestRunPanel from '../index' -// ============================================================================ -// Mocks -// ============================================================================ - -// Mock workflow store const mockIsPreparingDataSource = vi.fn(() => true) const mockSetIsPreparingDataSource = vi.fn() const mockWorkflowRunningData = vi.fn<() => WorkflowRunningData | undefined>(() => undefined) @@ -34,7 +28,6 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock workflow interactions const mockHandleCancelDebugAndPreviewPanel = vi.fn() vi.mock('@/app/components/workflow/hooks', () => ({ useWorkflowInteractions: () => ({ @@ -46,22 +39,18 @@ vi.mock('@/app/components/workflow/hooks', () => ({ useToolIcon: () => 'mock-tool-icon', })) -// Mock data source provider vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store/provider', () => ({ default: ({ children }: { children: React.ReactNode }) => <div data-testid="data-source-provider">{children}</div>, })) -// Mock Preparation component -vi.mock('./preparation', () => ({ +vi.mock('../preparation', () => ({ default: () => <div data-testid="preparation-component">Preparation</div>, })) -// Mock Result component (for TestRunPanel tests only) -vi.mock('./result', () => ({ +vi.mock('../result', () => ({ default: () => <div data-testid="result-component">Result</div>, })) -// Mock ResultPanel from workflow vi.mock('@/app/components/workflow/run/result-panel', () => ({ default: (props: Record<string, unknown>) => ( <div data-testid="result-panel"> @@ -72,7 +61,6 @@ vi.mock('@/app/components/workflow/run/result-panel', () => ({ ), })) -// Mock TracingPanel from workflow vi.mock('@/app/components/workflow/run/tracing-panel', () => ({ default: (props: { list: unknown[] }) => ( <div data-testid="tracing-panel"> @@ -85,20 +73,14 @@ vi.mock('@/app/components/workflow/run/tracing-panel', () => ({ ), })) -// Mock Loading component vi.mock('@/app/components/base/loading', () => ({ default: () => <div data-testid="loading">Loading...</div>, })) -// Mock config vi.mock('@/config', () => ({ RAG_PIPELINE_PREVIEW_CHUNK_NUM: 5, })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createMockWorkflowRunningData = (overrides: Partial<WorkflowRunningData> = {}): WorkflowRunningData => ({ result: { status: WorkflowRunningStatus.Succeeded, @@ -141,10 +123,6 @@ const createMockQAOutputs = () => ({ ], }) -// ============================================================================ -// TestRunPanel Component Tests -// ============================================================================ - describe('TestRunPanel', () => { beforeEach(() => { vi.clearAllMocks() @@ -152,7 +130,6 @@ describe('TestRunPanel', () => { mockWorkflowRunningData.mockReturnValue(undefined) }) - // Basic rendering tests describe('Rendering', () => { it('should render with correct container styles', () => { const { container } = render(<TestRunPanel />) @@ -168,7 +145,6 @@ describe('TestRunPanel', () => { }) }) - // Conditional rendering based on isPreparingDataSource describe('Conditional Content Rendering', () => { it('should render Preparation inside DataSourceProvider when isPreparingDataSource is true', () => { mockIsPreparingDataSource.mockReturnValue(true) @@ -192,17 +168,12 @@ describe('TestRunPanel', () => { }) }) -// ============================================================================ -// Header Component Tests -// ============================================================================ - describe('Header', () => { beforeEach(() => { vi.clearAllMocks() mockIsPreparingDataSource.mockReturnValue(true) }) - // Rendering tests describe('Rendering', () => { it('should render title with correct translation key', () => { render(<Header />) @@ -225,7 +196,6 @@ describe('Header', () => { }) }) - // Close button interactions describe('Close Button Interaction', () => { it('should call setIsPreparingDataSource(false) and handleCancelDebugAndPreviewPanel when clicked and isPreparingDataSource is true', () => { mockIsPreparingDataSource.mockReturnValue(true) @@ -253,19 +223,13 @@ describe('Header', () => { }) }) -// ============================================================================ -// Result Component Tests (Real Implementation) -// ============================================================================ - -// Unmock Result for these tests -vi.doUnmock('./result') +vi.doUnmock('../result') describe('Result', () => { - // Dynamically import Result to get real implementation - let Result: typeof import('./result').default + let Result: typeof import('../result').default beforeAll(async () => { - const resultModule = await import('./result') + const resultModule = await import('../result') Result = resultModule.default }) @@ -274,7 +238,6 @@ describe('Result', () => { mockWorkflowRunningData.mockReturnValue(undefined) }) - // Rendering tests describe('Rendering', () => { it('should render with RESULT tab active by default', async () => { render(<Result />) @@ -294,7 +257,6 @@ describe('Result', () => { }) }) - // Tab switching tests describe('Tab Switching', () => { it('should switch to DETAIL tab when clicked', async () => { mockWorkflowRunningData.mockReturnValue(createMockWorkflowRunningData()) @@ -321,7 +283,6 @@ describe('Result', () => { }) }) - // Loading states describe('Loading States', () => { it('should show loading in DETAIL tab when no result data', async () => { mockWorkflowRunningData.mockReturnValue({ @@ -352,18 +313,13 @@ describe('Result', () => { }) }) -// ============================================================================ -// ResultPreview Component Tests -// ============================================================================ - -// We need to import ResultPreview directly -vi.doUnmock('./result/result-preview') +vi.doUnmock('../result/result-preview') describe('ResultPreview', () => { - let ResultPreview: typeof import('./result/result-preview').default + let ResultPreview: typeof import('../result/result-preview').default beforeAll(async () => { - const previewModule = await import('./result/result-preview') + const previewModule = await import('../result/result-preview') ResultPreview = previewModule.default }) @@ -373,7 +329,6 @@ describe('ResultPreview', () => { vi.clearAllMocks() }) - // Loading state describe('Loading State', () => { it('should show loading spinner when isRunning is true and no outputs', () => { render( @@ -402,7 +357,6 @@ describe('ResultPreview', () => { }) }) - // Error state describe('Error State', () => { it('should show error message when not running and has error', () => { render( @@ -448,7 +402,6 @@ describe('ResultPreview', () => { }) }) - // Success state with outputs describe('Success State with Outputs', () => { it('should render chunk content when outputs are available', () => { render( @@ -460,7 +413,6 @@ describe('ResultPreview', () => { />, ) - // Check that chunk content is rendered (the real ChunkCardList renders the content) expect(screen.getByText('test chunk content')).toBeInTheDocument() }) @@ -492,7 +444,6 @@ describe('ResultPreview', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty outputs gracefully', () => { render( @@ -504,7 +455,6 @@ describe('ResultPreview', () => { />, ) - // Should not crash and should not show chunk card list expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument() }) @@ -523,17 +473,13 @@ describe('ResultPreview', () => { }) }) -// ============================================================================ -// Tabs Component Tests -// ============================================================================ - -vi.doUnmock('./result/tabs') +vi.doUnmock('../result/tabs') describe('Tabs', () => { - let Tabs: typeof import('./result/tabs').default + let Tabs: typeof import('../result/tabs').default beforeAll(async () => { - const tabsModule = await import('./result/tabs') + const tabsModule = await import('../result/tabs') Tabs = tabsModule.default }) @@ -543,7 +489,6 @@ describe('Tabs', () => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render all three tabs', () => { render( @@ -560,7 +505,6 @@ describe('Tabs', () => { }) }) - // Active tab styling describe('Active Tab Styling', () => { it('should highlight RESULT tab when currentTab is RESULT', () => { render( @@ -589,7 +533,6 @@ describe('Tabs', () => { }) }) - // Tab click handling describe('Tab Click Handling', () => { it('should call switchTab with RESULT when RESULT tab is clicked', () => { render( @@ -634,7 +577,6 @@ describe('Tabs', () => { }) }) - // Disabled state when no data describe('Disabled State', () => { it('should disable tabs when workflowRunningData is undefined', () => { render( @@ -651,17 +593,13 @@ describe('Tabs', () => { }) }) -// ============================================================================ -// Tab Component Tests -// ============================================================================ - -vi.doUnmock('./result/tabs/tab') +vi.doUnmock('../result/tabs/tab') describe('Tab', () => { - let Tab: typeof import('./result/tabs/tab').default + let Tab: typeof import('../result/tabs/tab').default beforeAll(async () => { - const tabModule = await import('./result/tabs/tab') + const tabModule = await import('../result/tabs/tab') Tab = tabModule.default }) @@ -671,7 +609,6 @@ describe('Tab', () => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render tab with label', () => { render( @@ -688,7 +625,6 @@ describe('Tab', () => { }) }) - // Active state styling describe('Active State', () => { it('should have active styles when isActive is true', () => { render( @@ -721,7 +657,6 @@ describe('Tab', () => { }) }) - // Click handling describe('Click Handling', () => { it('should call onClick with value when clicked', () => { render( @@ -753,12 +688,10 @@ describe('Tab', () => { const tab = screen.getByRole('button') fireEvent.click(tab) - // The click handler is still called, but button is disabled expect(tab).toBeDisabled() }) }) - // Disabled state describe('Disabled State', () => { it('should be disabled when workflowRunningData is undefined', () => { render( @@ -793,19 +726,14 @@ describe('Tab', () => { }) }) -// ============================================================================ -// formatPreviewChunks Utility Tests -// ============================================================================ - describe('formatPreviewChunks', () => { - let formatPreviewChunks: typeof import('./result/result-preview/utils').formatPreviewChunks + let formatPreviewChunks: typeof import('../result/result-preview/utils').formatPreviewChunks beforeAll(async () => { - const utilsModule = await import('./result/result-preview/utils') + const utilsModule = await import('../result/result-preview/utils') formatPreviewChunks = utilsModule.formatPreviewChunks }) - // Edge cases describe('Edge Cases', () => { it('should return undefined for null outputs', () => { expect(formatPreviewChunks(null)).toBeUndefined() @@ -824,7 +752,6 @@ describe('formatPreviewChunks', () => { }) }) - // General (text) chunks describe('General Chunks (ChunkingMode.text)', () => { it('should format general chunks correctly', () => { const outputs = createMockGeneralOutputs(['content1', 'content2', 'content3']) @@ -842,7 +769,6 @@ describe('formatPreviewChunks', () => { const outputs = createMockGeneralOutputs(manyChunks) const result = formatPreviewChunks(outputs) as GeneralChunks - // RAG_PIPELINE_PREVIEW_CHUNK_NUM is mocked to 5 expect(result).toHaveLength(5) expect(result).toEqual([ { content: 'chunk0', summary: undefined }, @@ -861,7 +787,6 @@ describe('formatPreviewChunks', () => { }) }) - // Parent-child chunks describe('Parent-Child Chunks (ChunkingMode.parentChild)', () => { it('should format paragraph mode parent-child chunks correctly', () => { const outputs = createMockParentChildOutputs('paragraph') @@ -902,7 +827,6 @@ describe('formatPreviewChunks', () => { }) }) - // QA chunks describe('QA Chunks (ChunkingMode.qa)', () => { it('should format QA chunks correctly', () => { const outputs = createMockQAOutputs() @@ -931,14 +855,10 @@ describe('formatPreviewChunks', () => { }) }) -// ============================================================================ -// Types Tests -// ============================================================================ - describe('Types', () => { describe('TestRunStep Enum', () => { it('should have correct enum values', async () => { - const { TestRunStep } = await import('./types') + const { TestRunStep } = await import('../types') expect(TestRunStep.dataSource).toBe('dataSource') expect(TestRunStep.documentProcessing).toBe('documentProcessing') diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..ee65a9d65c --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/hooks.spec.ts @@ -0,0 +1,232 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { renderHook } from '@testing-library/react' +import { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { BlockEnum } from '@/app/components/workflow/types' +import { useDatasourceOptions, useOnlineDocument, useOnlineDrive, useTestRunSteps, useWebsiteCrawl } from '../hooks' + +const mockNodes: Array<{ id: string, data: Partial<DataSourceNodeType> & { type: string } }> = [] +vi.mock('reactflow', () => ({ + useNodes: () => mockNodes, +})) + +const mockDataSourceStoreGetState = vi.fn() +vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({ + useDataSourceStore: () => ({ + getState: mockDataSourceStoreGetState, + }), +})) + +vi.mock('@/app/components/workflow/types', async () => { + const actual = await vi.importActual<typeof import('@/app/components/workflow/types')>('@/app/components/workflow/types') + return { + ...actual, + BlockEnum: { + ...actual.BlockEnum, + DataSource: 'data-source', + }, + } +}) + +vi.mock('../../types', () => ({ + TestRunStep: { + dataSource: 'dataSource', + documentProcessing: 'documentProcessing', + }, +})) + +vi.mock('@/models/datasets', () => ({ + CrawlStep: { + init: 'init', + }, +})) + +describe('useTestRunSteps', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('should initialize with step 1', () => { + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.currentStep).toBe(1) + }) + + it('should return 2 steps (dataSource and documentProcessing)', () => { + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.steps).toHaveLength(2) + expect(result.current.steps[0].value).toBe('dataSource') + expect(result.current.steps[1].value).toBe('documentProcessing') + }) + + it('should increment step on handleNextStep', () => { + const { result } = renderHook(() => useTestRunSteps()) + + act(() => { + result.current.handleNextStep() + }) + + expect(result.current.currentStep).toBe(2) + }) + + it('should decrement step on handleBackStep', () => { + const { result } = renderHook(() => useTestRunSteps()) + + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(1) + }) + + it('should have translated step labels', () => { + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.steps[0].label).toBeDefined() + expect(typeof result.current.steps[0].label).toBe('string') + }) +}) + +describe('useDatasourceOptions', () => { + beforeEach(() => { + mockNodes.length = 0 + vi.clearAllMocks() + }) + + it('should return empty options when no DataSource nodes', () => { + mockNodes.push({ id: 'n1', data: { type: BlockEnum.LLM, title: 'LLM' } }) + + const { result } = renderHook(() => useDatasourceOptions()) + + expect(result.current).toEqual([]) + }) + + it('should return options from DataSource nodes', () => { + mockNodes.push( + { id: 'ds-1', data: { type: BlockEnum.DataSource, title: 'Source A' } }, + { id: 'ds-2', data: { type: BlockEnum.DataSource, title: 'Source B' } }, + ) + + const { result } = renderHook(() => useDatasourceOptions()) + + expect(result.current).toHaveLength(2) + expect(result.current[0]).toEqual({ + label: 'Source A', + value: 'ds-1', + data: expect.objectContaining({ type: 'data-source' }), + }) + expect(result.current[1]).toEqual({ + label: 'Source B', + value: 'ds-2', + data: expect.objectContaining({ type: 'data-source' }), + }) + }) + + it('should filter out non-DataSource nodes', () => { + mockNodes.push( + { id: 'ds-1', data: { type: BlockEnum.DataSource, title: 'Source' } }, + { id: 'llm-1', data: { type: BlockEnum.LLM, title: 'LLM' } }, + { id: 'end-1', data: { type: BlockEnum.End, title: 'End' } }, + ) + + const { result } = renderHook(() => useDatasourceOptions()) + + expect(result.current).toHaveLength(1) + expect(result.current[0].value).toBe('ds-1') + }) +}) + +describe('useOnlineDocument', () => { + it('should clear all online document data', () => { + const mockSetDocumentsData = vi.fn() + const mockSetSearchValue = vi.fn() + const mockSetSelectedPagesId = vi.fn() + const mockSetOnlineDocuments = vi.fn() + const mockSetCurrentDocument = vi.fn() + + mockDataSourceStoreGetState.mockReturnValue({ + setDocumentsData: mockSetDocumentsData, + setSearchValue: mockSetSearchValue, + setSelectedPagesId: mockSetSelectedPagesId, + setOnlineDocuments: mockSetOnlineDocuments, + setCurrentDocument: mockSetCurrentDocument, + }) + + const { result } = renderHook(() => useOnlineDocument()) + + act(() => { + result.current.clearOnlineDocumentData() + }) + + expect(mockSetDocumentsData).toHaveBeenCalledWith([]) + expect(mockSetSearchValue).toHaveBeenCalledWith('') + expect(mockSetSelectedPagesId).toHaveBeenCalledWith(new Set()) + expect(mockSetOnlineDocuments).toHaveBeenCalledWith([]) + expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined) + }) +}) + +describe('useWebsiteCrawl', () => { + it('should clear all website crawl data', () => { + const mockSetStep = vi.fn() + const mockSetCrawlResult = vi.fn() + const mockSetWebsitePages = vi.fn() + const mockSetPreviewIndex = vi.fn() + const mockSetCurrentWebsite = vi.fn() + + mockDataSourceStoreGetState.mockReturnValue({ + setStep: mockSetStep, + setCrawlResult: mockSetCrawlResult, + setWebsitePages: mockSetWebsitePages, + setPreviewIndex: mockSetPreviewIndex, + setCurrentWebsite: mockSetCurrentWebsite, + }) + + const { result } = renderHook(() => useWebsiteCrawl()) + + act(() => { + result.current.clearWebsiteCrawlData() + }) + + expect(mockSetStep).toHaveBeenCalledWith('init') + expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined) + expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined) + expect(mockSetWebsitePages).toHaveBeenCalledWith([]) + expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1) + }) +}) + +describe('useOnlineDrive', () => { + it('should clear all online drive data', () => { + const mockSetOnlineDriveFileList = vi.fn() + const mockSetBucket = vi.fn() + const mockSetPrefix = vi.fn() + const mockSetKeywords = vi.fn() + const mockSetSelectedFileIds = vi.fn() + + mockDataSourceStoreGetState.mockReturnValue({ + setOnlineDriveFileList: mockSetOnlineDriveFileList, + setBucket: mockSetBucket, + setPrefix: mockSetPrefix, + setKeywords: mockSetKeywords, + setSelectedFileIds: mockSetSelectedFileIds, + }) + + const { result } = renderHook(() => useOnlineDrive()) + + act(() => { + result.current.clearOnlineDriveData() + }) + + expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockSetBucket).toHaveBeenCalledWith('') + expect(mockSetPrefix).toHaveBeenCalledWith([]) + expect(mockSetKeywords).toHaveBeenCalledWith('') + expect(mockSetSelectedFileIds).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx similarity index 76% rename from web/app/components/rag-pipeline/components/panel/test-run/preparation/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx index 0aa7df0fa8..a7956927c1 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx @@ -1,27 +1,21 @@ -import type { Datasource } from '../types' +import type { Datasource } from '../../types' import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { DatasourceType } from '@/models/pipeline' -import FooterTips from './footer-tips' +import FooterTips from '../footer-tips' import { useDatasourceOptions, useOnlineDocument, useOnlineDrive, useTestRunSteps, useWebsiteCrawl, -} from './hooks' -import Preparation from './index' -import StepIndicator from './step-indicator' +} from '../hooks' +import Preparation from '../index' +import StepIndicator from '../step-indicator' -// ============================================================================ -// Pre-declare variables and functions used in mocks (hoisting) -// ============================================================================ - -// Mock Nodes for useDatasourceOptions - must be declared before vi.mock let mockNodes: Array<{ id: string, data: DataSourceNodeType }> = [] -// Test Data Factory - must be declared before vi.mock that uses it const createNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ title: 'Test Node', desc: 'Test description', @@ -36,39 +30,18 @@ const createNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNode ...overrides, } as unknown as DataSourceNodeType) -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const ns = options?.ns ? `${options.ns}.` : '' - return `${ns}${key}` - }, - }), -})) - -// Mock reactflow vi.mock('reactflow', () => ({ useNodes: () => mockNodes, })) -// Mock zustand/react/shallow vi.mock('zustand/react/shallow', () => ({ useShallow: <T,>(fn: (state: unknown) => T) => fn, })) -// Mock amplitude tracking vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -// ============================================================================ -// Mock Data Source Store -// ============================================================================ - let mockDataSourceStoreState = { localFileList: [] as Array<{ file: { id: string, name: string, type: string, size: number, extension: string, mime_type: string } }>, onlineDocuments: [] as Array<{ workspace_id: string, page_id?: string, title?: string }>, @@ -103,10 +76,6 @@ vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/st useDataSourceStoreWithSelector: <T,>(selector: (state: typeof mockDataSourceStoreState) => T) => selector(mockDataSourceStoreState), })) -// ============================================================================ -// Mock Workflow Store -// ============================================================================ - let mockWorkflowStoreState = { setIsPreparingDataSource: vi.fn(), pipelineId: 'test-pipeline-id', @@ -119,10 +88,6 @@ vi.mock('@/app/components/workflow/store', () => ({ useStore: <T,>(selector: (state: typeof mockWorkflowStoreState) => T) => selector(mockWorkflowStoreState), })) -// ============================================================================ -// Mock Workflow Hooks -// ============================================================================ - const mockHandleRun = vi.fn() vi.mock('@/app/components/workflow/hooks', () => ({ @@ -132,10 +97,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({ useToolIcon: () => ({ type: 'icon', icon: 'test-icon' }), })) -// ============================================================================ -// Mock Child Components -// ============================================================================ - vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/local-file', () => ({ default: ({ allowedExtensions, supportBatchUpload }: { allowedExtensions: string[], supportBatchUpload: boolean }) => ( <div data-testid="local-file" data-extensions={JSON.stringify(allowedExtensions)} data-batch={supportBatchUpload}> @@ -179,7 +140,7 @@ vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/on ), })) -vi.mock('./data-source-options', () => ({ +vi.mock('../data-source-options', () => ({ default: ({ dataSourceNodeId, onSelect }: { dataSourceNodeId: string, onSelect: (ds: Datasource) => void }) => ( <div data-testid="data-source-options" data-selected={dataSourceNodeId}> <button @@ -232,7 +193,7 @@ vi.mock('./data-source-options', () => ({ ), })) -vi.mock('./document-processing', () => ({ +vi.mock('../document-processing', () => ({ default: ({ dataSourceNodeId, onProcess, onBack }: { dataSourceNodeId: string, onProcess: (data: Record<string, unknown>) => void, onBack: () => void }) => ( <div data-testid="document-processing" data-node-id={dataSourceNodeId}> <button data-testid="process-btn" onClick={() => onProcess({ field1: 'value1' })}>Process</button> @@ -242,10 +203,6 @@ vi.mock('./document-processing', () => ({ ), })) -// ============================================================================ -// Helper to reset all mocks -// ============================================================================ - const resetAllMocks = () => { mockDataSourceStoreState = { localFileList: [], @@ -281,10 +238,6 @@ const resetAllMocks = () => { mockHandleRun.mockClear() } -// ============================================================================ -// StepIndicator Component Tests -// ============================================================================ - describe('StepIndicator', () => { beforeEach(() => { vi.clearAllMocks() @@ -296,40 +249,30 @@ describe('StepIndicator', () => { { label: 'Step 3', value: 'step3' }, ] - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<StepIndicator steps={defaultSteps} currentStep={1} />) - // Assert expect(screen.getByText('Step 1')).toBeInTheDocument() expect(screen.getByText('Step 2')).toBeInTheDocument() expect(screen.getByText('Step 3')).toBeInTheDocument() }) it('should render all step labels', () => { - // Arrange const steps = [ { label: 'Data Source', value: 'dataSource' }, { label: 'Processing', value: 'processing' }, ] - // Act render(<StepIndicator steps={steps} currentStep={1} />) - // Assert expect(screen.getByText('Data Source')).toBeInTheDocument() expect(screen.getByText('Processing')).toBeInTheDocument() }) it('should render container with correct classes', () => { - // Arrange & Act const { container } = render(<StepIndicator steps={defaultSteps} currentStep={1} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('flex') expect(wrapper.className).toContain('items-center') @@ -339,112 +282,82 @@ describe('StepIndicator', () => { }) it('should render divider between steps but not after last step', () => { - // Arrange & Act const { container } = render(<StepIndicator steps={defaultSteps} currentStep={1} />) - // Assert - Should have 2 dividers for 3 steps const dividers = container.querySelectorAll('.h-px.w-3') expect(dividers.length).toBe(2) }) it('should not render divider when there is only one step', () => { - // Arrange const singleStep = [{ label: 'Only Step', value: 'only' }] - // Act const { container } = render(<StepIndicator steps={singleStep} currentStep={1} />) - // Assert const dividers = container.querySelectorAll('.h-px.w-3') expect(dividers.length).toBe(0) }) }) - // ------------------------------------------------------------------------- - // Props Variations Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should highlight first step when currentStep is 1', () => { - // Arrange & Act const { container } = render(<StepIndicator steps={defaultSteps} currentStep={1} />) - // Assert - Check for accent indicator on first step const indicators = container.querySelectorAll('.bg-state-accent-solid') expect(indicators.length).toBe(1) // The dot indicator }) it('should highlight second step when currentStep is 2', () => { - // Arrange & Act render(<StepIndicator steps={defaultSteps} currentStep={2} />) - // Assert const step2Container = screen.getByText('Step 2').parentElement expect(step2Container?.className).toContain('text-state-accent-solid') }) it('should highlight third step when currentStep is 3', () => { - // Arrange & Act render(<StepIndicator steps={defaultSteps} currentStep={3} />) - // Assert const step3Container = screen.getByText('Step 3').parentElement expect(step3Container?.className).toContain('text-state-accent-solid') }) it('should apply tertiary color to non-current steps', () => { - // Arrange & Act render(<StepIndicator steps={defaultSteps} currentStep={1} />) - // Assert const step2Container = screen.getByText('Step 2').parentElement expect(step2Container?.className).toContain('text-text-tertiary') }) it('should show dot indicator only for current step', () => { - // Arrange & Act const { container } = render(<StepIndicator steps={defaultSteps} currentStep={2} />) - // Assert - Only one dot should exist const dots = container.querySelectorAll('.size-1.rounded-full') expect(dots.length).toBe(1) }) it('should handle empty steps array', () => { - // Arrange & Act const { container } = render(<StepIndicator steps={[]} currentStep={1} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange & Act const { rerender } = render(<StepIndicator steps={defaultSteps} currentStep={1} />) - // Rerender with same props rerender(<StepIndicator steps={defaultSteps} currentStep={1} />) - // Assert - Component should render correctly expect(screen.getByText('Step 1')).toBeInTheDocument() }) it('should update when currentStep changes', () => { - // Arrange const { rerender } = render(<StepIndicator steps={defaultSteps} currentStep={1} />) - // Assert initial state let step1Container = screen.getByText('Step 1').parentElement expect(step1Container?.className).toContain('text-state-accent-solid') - // Act - Change step rerender(<StepIndicator steps={defaultSteps} currentStep={2} />) - // Assert step1Container = screen.getByText('Step 1').parentElement expect(step1Container?.className).toContain('text-text-tertiary') const step2Container = screen.getByText('Step 2').parentElement @@ -452,130 +365,95 @@ describe('StepIndicator', () => { }) it('should update when steps array changes', () => { - // Arrange const { rerender } = render(<StepIndicator steps={defaultSteps} currentStep={1} />) - // Act const newSteps = [ { label: 'New Step 1', value: 'new1' }, { label: 'New Step 2', value: 'new2' }, ] rerender(<StepIndicator steps={newSteps} currentStep={1} />) - // Assert expect(screen.getByText('New Step 1')).toBeInTheDocument() expect(screen.getByText('New Step 2')).toBeInTheDocument() expect(screen.queryByText('Step 3')).not.toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle currentStep of 0', () => { - // Arrange & Act const { container } = render(<StepIndicator steps={defaultSteps} currentStep={0} />) - // Assert - No step should be highlighted (currentStep - 1 = -1) const dots = container.querySelectorAll('.size-1.rounded-full') expect(dots.length).toBe(0) }) it('should handle currentStep greater than steps length', () => { - // Arrange & Act const { container } = render(<StepIndicator steps={defaultSteps} currentStep={10} />) - // Assert - No step should be highlighted const dots = container.querySelectorAll('.size-1.rounded-full') expect(dots.length).toBe(0) }) it('should handle steps with empty labels', () => { - // Arrange const stepsWithEmpty = [ { label: '', value: 'empty' }, { label: 'Valid', value: 'valid' }, ] - // Act render(<StepIndicator steps={stepsWithEmpty} currentStep={1} />) - // Assert expect(screen.getByText('Valid')).toBeInTheDocument() }) it('should handle steps with very long labels', () => { - // Arrange const longLabel = 'A'.repeat(100) const stepsWithLong = [{ label: longLabel, value: 'long' }] - // Act render(<StepIndicator steps={stepsWithLong} currentStep={1} />) - // Assert expect(screen.getByText(longLabel)).toBeInTheDocument() }) it('should handle special characters in labels', () => { - // Arrange const specialSteps = [{ label: '<Test> & "Label"', value: 'special' }] - // Act render(<StepIndicator steps={specialSteps} currentStep={1} />) - // Assert expect(screen.getByText('<Test> & "Label"')).toBeInTheDocument() }) it('should handle unicode characters in labels', () => { - // Arrange const unicodeSteps = [{ label: 'æ•°æźæș 🎉', value: 'unicode' }] - // Act render(<StepIndicator steps={unicodeSteps} currentStep={1} />) - // Assert expect(screen.getByText('æ•°æźæș 🎉')).toBeInTheDocument() }) it('should handle negative currentStep', () => { - // Arrange & Act const { container } = render(<StepIndicator steps={defaultSteps} currentStep={-1} />) - // Assert - No step should be highlighted const dots = container.querySelectorAll('.size-1.rounded-full') expect(dots.length).toBe(0) }) }) }) -// ============================================================================ -// FooterTips Component Tests -// ============================================================================ - describe('FooterTips', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<FooterTips />) - // Assert - Check for translated text expect(screen.getByText('datasetPipeline.testRun.tooltip')).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render(<FooterTips />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('system-xs-regular') expect(wrapper.className).toContain('flex') @@ -588,226 +466,161 @@ describe('FooterTips', () => { }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange & Act const { rerender } = render(<FooterTips />) - // Rerender rerender(<FooterTips />) - // Assert expect(screen.getByText('datasetPipeline.testRun.tooltip')).toBeInTheDocument() }) it('should render consistently across multiple rerenders', () => { - // Arrange const { rerender } = render(<FooterTips />) - // Act - Multiple rerenders for (let i = 0; i < 5; i++) rerender(<FooterTips />) - // Assert expect(screen.getByText('datasetPipeline.testRun.tooltip')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle unmount cleanly', () => { - // Arrange const { unmount } = render(<FooterTips />) - // Assert expect(() => unmount()).not.toThrow() }) }) }) -// ============================================================================ -// useTestRunSteps Hook Tests -// ============================================================================ - describe('useTestRunSteps', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Initial State Tests - // ------------------------------------------------------------------------- describe('Initial State', () => { it('should initialize with currentStep as 1', () => { - // Arrange & Act const { result } = renderHook(() => useTestRunSteps()) - // Assert expect(result.current.currentStep).toBe(1) }) it('should provide steps array with data source and document processing steps', () => { - // Arrange & Act const { result } = renderHook(() => useTestRunSteps()) - // Assert expect(result.current.steps).toHaveLength(2) expect(result.current.steps[0].value).toBe('dataSource') expect(result.current.steps[1].value).toBe('documentProcessing') }) it('should provide translated step labels', () => { - // Arrange & Act const { result } = renderHook(() => useTestRunSteps()) - // Assert expect(result.current.steps[0].label).toContain('testRun.steps.dataSource') expect(result.current.steps[1].label).toContain('testRun.steps.documentProcessing') }) }) - // ------------------------------------------------------------------------- - // handleNextStep Tests - // ------------------------------------------------------------------------- describe('handleNextStep', () => { it('should increment currentStep by 1', () => { - // Arrange const { result } = renderHook(() => useTestRunSteps()) - // Act act(() => { result.current.handleNextStep() }) - // Assert expect(result.current.currentStep).toBe(2) }) it('should continue incrementing on multiple calls', () => { - // Arrange const { result } = renderHook(() => useTestRunSteps()) - // Act act(() => { result.current.handleNextStep() result.current.handleNextStep() result.current.handleNextStep() }) - // Assert expect(result.current.currentStep).toBe(4) }) }) - // ------------------------------------------------------------------------- - // handleBackStep Tests - // ------------------------------------------------------------------------- describe('handleBackStep', () => { it('should decrement currentStep by 1', () => { - // Arrange const { result } = renderHook(() => useTestRunSteps()) - // First go to step 2 act(() => { result.current.handleNextStep() }) expect(result.current.currentStep).toBe(2) - // Act act(() => { result.current.handleBackStep() }) - // Assert expect(result.current.currentStep).toBe(1) }) it('should allow going to negative steps (no validation)', () => { - // Arrange const { result } = renderHook(() => useTestRunSteps()) - // Act act(() => { result.current.handleBackStep() }) - // Assert expect(result.current.currentStep).toBe(0) }) it('should continue decrementing on multiple calls', () => { - // Arrange const { result } = renderHook(() => useTestRunSteps()) - // Go to step 5 act(() => { for (let i = 0; i < 4; i++) result.current.handleNextStep() }) expect(result.current.currentStep).toBe(5) - // Act - Go back 3 steps act(() => { result.current.handleBackStep() result.current.handleBackStep() result.current.handleBackStep() }) - // Assert expect(result.current.currentStep).toBe(2) }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should return stable handleNextStep callback', () => { - // Arrange const { result, rerender } = renderHook(() => useTestRunSteps()) const initialCallback = result.current.handleNextStep - // Act rerender() - // Assert expect(result.current.handleNextStep).toBe(initialCallback) }) it('should return stable handleBackStep callback', () => { - // Arrange const { result, rerender } = renderHook(() => useTestRunSteps()) const initialCallback = result.current.handleBackStep - // Act rerender() - // Assert expect(result.current.handleBackStep).toBe(initialCallback) }) }) - // ------------------------------------------------------------------------- - // Integration Tests - // ------------------------------------------------------------------------- describe('Integration', () => { it('should handle forward and backward navigation', () => { - // Arrange const { result } = renderHook(() => useTestRunSteps()) - // Act & Assert - Navigate forward act(() => result.current.handleNextStep()) expect(result.current.currentStep).toBe(2) act(() => result.current.handleNextStep()) expect(result.current.currentStep).toBe(3) - // Act & Assert - Navigate backward act(() => result.current.handleBackStep()) expect(result.current.currentStep).toBe(2) @@ -817,33 +630,22 @@ describe('useTestRunSteps', () => { }) }) -// ============================================================================ -// useDatasourceOptions Hook Tests -// ============================================================================ - describe('useDatasourceOptions', () => { beforeEach(() => { vi.clearAllMocks() resetAllMocks() }) - // ------------------------------------------------------------------------- - // Basic Functionality Tests - // ------------------------------------------------------------------------- describe('Basic Functionality', () => { it('should return empty array when no nodes exist', () => { - // Arrange mockNodes = [] - // Act const { result } = renderHook(() => useDatasourceOptions()) - // Assert expect(result.current).toEqual([]) }) it('should return empty array when no DataSource nodes exist', () => { - // Arrange mockNodes = [ { id: 'node-1', @@ -854,15 +656,12 @@ describe('useDatasourceOptions', () => { }, ] - // Act const { result } = renderHook(() => useDatasourceOptions()) - // Assert expect(result.current).toEqual([]) }) it('should return options for DataSource nodes only', () => { - // Arrange mockNodes = [ { id: 'datasource-1', @@ -887,10 +686,8 @@ describe('useDatasourceOptions', () => { }, ] - // Act const { result } = renderHook(() => useDatasourceOptions()) - // Assert expect(result.current).toHaveLength(2) expect(result.current[0]).toEqual({ label: 'Local File Source', @@ -905,7 +702,6 @@ describe('useDatasourceOptions', () => { }) it('should map node id to option value', () => { - // Arrange mockNodes = [ { id: 'unique-node-id-123', @@ -916,15 +712,12 @@ describe('useDatasourceOptions', () => { }, ] - // Act const { result } = renderHook(() => useDatasourceOptions()) - // Assert expect(result.current[0].value).toBe('unique-node-id-123') }) it('should map node title to option label', () => { - // Arrange mockNodes = [ { id: 'node-1', @@ -935,15 +728,12 @@ describe('useDatasourceOptions', () => { }, ] - // Act const { result } = renderHook(() => useDatasourceOptions()) - // Assert expect(result.current[0].label).toBe('Custom Data Source Title') }) it('should include full node data in option', () => { - // Arrange const nodeData = { ...createNodeData({ title: 'Full Data Test', @@ -960,20 +750,14 @@ describe('useDatasourceOptions', () => { }, ] - // Act const { result } = renderHook(() => useDatasourceOptions()) - // Assert expect(result.current[0].data).toEqual(nodeData) }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should return same options reference when nodes do not change', () => { - // Arrange mockNodes = [ { id: 'node-1', @@ -984,18 +768,15 @@ describe('useDatasourceOptions', () => { }, ] - // Act const { result, rerender } = renderHook(() => useDatasourceOptions()) rerender() - // Assert - Options should be memoized and still work correctly after rerender expect(result.current).toHaveLength(1) expect(result.current[0].label).toBe('Test') }) it('should update options when nodes change', () => { - // Arrange mockNodes = [ { id: 'node-1', @@ -1010,7 +791,6 @@ describe('useDatasourceOptions', () => { expect(result.current).toHaveLength(1) expect(result.current[0].label).toBe('First') - // Act - Change nodes mockNodes = [ { id: 'node-2', @@ -1029,19 +809,14 @@ describe('useDatasourceOptions', () => { ] rerender() - // Assert expect(result.current).toHaveLength(2) expect(result.current[0].label).toBe('Second') expect(result.current[1].label).toBe('Third') }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle nodes with empty title', () => { - // Arrange mockNodes = [ { id: 'node-1', @@ -1052,15 +827,12 @@ describe('useDatasourceOptions', () => { }, ] - // Act const { result } = renderHook(() => useDatasourceOptions()) - // Assert expect(result.current[0].label).toBe('') }) it('should handle multiple DataSource nodes', () => { - // Arrange mockNodes = Array.from({ length: 10 }, (_, i) => ({ id: `node-${i}`, data: { @@ -1069,10 +841,8 @@ describe('useDatasourceOptions', () => { } as DataSourceNodeType, })) - // Act const { result } = renderHook(() => useDatasourceOptions()) - // Assert expect(result.current).toHaveLength(10) result.current.forEach((option, i) => { expect(option.value).toBe(`node-${i}`) @@ -1082,30 +852,20 @@ describe('useDatasourceOptions', () => { }) }) -// ============================================================================ -// useOnlineDocument Hook Tests -// ============================================================================ - describe('useOnlineDocument', () => { beforeEach(() => { vi.clearAllMocks() resetAllMocks() }) - // ------------------------------------------------------------------------- - // clearOnlineDocumentData Tests - // ------------------------------------------------------------------------- describe('clearOnlineDocumentData', () => { it('should clear all online document related data', () => { - // Arrange const { result } = renderHook(() => useOnlineDocument()) - // Act act(() => { result.current.clearOnlineDocumentData() }) - // Assert expect(mockDataSourceStoreState.setDocumentsData).toHaveBeenCalledWith([]) expect(mockDataSourceStoreState.setSearchValue).toHaveBeenCalledWith('') expect(mockDataSourceStoreState.setSelectedPagesId).toHaveBeenCalledWith(new Set()) @@ -1114,7 +874,6 @@ describe('useOnlineDocument', () => { }) it('should call all clear functions in correct order', () => { - // Arrange const { result } = renderHook(() => useOnlineDocument()) const callOrder: string[] = [] mockDataSourceStoreState.setDocumentsData = vi.fn(() => callOrder.push('setDocumentsData')) @@ -1123,12 +882,10 @@ describe('useOnlineDocument', () => { mockDataSourceStoreState.setOnlineDocuments = vi.fn(() => callOrder.push('setOnlineDocuments')) mockDataSourceStoreState.setCurrentDocument = vi.fn(() => callOrder.push('setCurrentDocument')) - // Act act(() => { result.current.clearOnlineDocumentData() }) - // Assert expect(callOrder).toEqual([ 'setDocumentsData', 'setSearchValue', @@ -1139,58 +896,40 @@ describe('useOnlineDocument', () => { }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should maintain functional callback after rerender', () => { - // Arrange const { result, rerender } = renderHook(() => useOnlineDocument()) - // Act - First call act(() => { result.current.clearOnlineDocumentData() }) const firstCallCount = mockDataSourceStoreState.setDocumentsData.mock.calls.length - // Rerender rerender() - // Act - Second call after rerender act(() => { result.current.clearOnlineDocumentData() }) - // Assert - Callback should still work after rerender expect(mockDataSourceStoreState.setDocumentsData.mock.calls.length).toBe(firstCallCount + 1) }) }) }) -// ============================================================================ -// useWebsiteCrawl Hook Tests -// ============================================================================ - describe('useWebsiteCrawl', () => { beforeEach(() => { vi.clearAllMocks() resetAllMocks() }) - // ------------------------------------------------------------------------- - // clearWebsiteCrawlData Tests - // ------------------------------------------------------------------------- describe('clearWebsiteCrawlData', () => { it('should clear all website crawl related data', () => { - // Arrange const { result } = renderHook(() => useWebsiteCrawl()) - // Act act(() => { result.current.clearWebsiteCrawlData() }) - // Assert expect(mockDataSourceStoreState.setStep).toHaveBeenCalledWith('init') expect(mockDataSourceStoreState.setCrawlResult).toHaveBeenCalledWith(undefined) expect(mockDataSourceStoreState.setCurrentWebsite).toHaveBeenCalledWith(undefined) @@ -1199,7 +938,6 @@ describe('useWebsiteCrawl', () => { }) it('should call all clear functions in correct order', () => { - // Arrange const { result } = renderHook(() => useWebsiteCrawl()) const callOrder: string[] = [] mockDataSourceStoreState.setStep = vi.fn(() => callOrder.push('setStep')) @@ -1208,12 +946,10 @@ describe('useWebsiteCrawl', () => { mockDataSourceStoreState.setWebsitePages = vi.fn(() => callOrder.push('setWebsitePages')) mockDataSourceStoreState.setPreviewIndex = vi.fn(() => callOrder.push('setPreviewIndex')) - // Act act(() => { result.current.clearWebsiteCrawlData() }) - // Assert expect(callOrder).toEqual([ 'setStep', 'setCrawlResult', @@ -1224,58 +960,40 @@ describe('useWebsiteCrawl', () => { }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should maintain functional callback after rerender', () => { - // Arrange const { result, rerender } = renderHook(() => useWebsiteCrawl()) - // Act - First call act(() => { result.current.clearWebsiteCrawlData() }) const firstCallCount = mockDataSourceStoreState.setStep.mock.calls.length - // Rerender rerender() - // Act - Second call after rerender act(() => { result.current.clearWebsiteCrawlData() }) - // Assert - Callback should still work after rerender expect(mockDataSourceStoreState.setStep.mock.calls.length).toBe(firstCallCount + 1) }) }) }) -// ============================================================================ -// useOnlineDrive Hook Tests -// ============================================================================ - describe('useOnlineDrive', () => { beforeEach(() => { vi.clearAllMocks() resetAllMocks() }) - // ------------------------------------------------------------------------- - // clearOnlineDriveData Tests - // ------------------------------------------------------------------------- describe('clearOnlineDriveData', () => { it('should clear all online drive related data', () => { - // Arrange const { result } = renderHook(() => useOnlineDrive()) - // Act act(() => { result.current.clearOnlineDriveData() }) - // Assert expect(mockDataSourceStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockDataSourceStoreState.setBucket).toHaveBeenCalledWith('') expect(mockDataSourceStoreState.setPrefix).toHaveBeenCalledWith([]) @@ -1284,7 +1002,6 @@ describe('useOnlineDrive', () => { }) it('should call all clear functions in correct order', () => { - // Arrange const { result } = renderHook(() => useOnlineDrive()) const callOrder: string[] = [] mockDataSourceStoreState.setOnlineDriveFileList = vi.fn(() => callOrder.push('setOnlineDriveFileList')) @@ -1293,12 +1010,10 @@ describe('useOnlineDrive', () => { mockDataSourceStoreState.setKeywords = vi.fn(() => callOrder.push('setKeywords')) mockDataSourceStoreState.setSelectedFileIds = vi.fn(() => callOrder.push('setSelectedFileIds')) - // Act act(() => { result.current.clearOnlineDriveData() }) - // Assert expect(callOrder).toEqual([ 'setOnlineDriveFileList', 'setBucket', @@ -1309,398 +1024,291 @@ describe('useOnlineDrive', () => { }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should maintain functional callback after rerender', () => { - // Arrange const { result, rerender } = renderHook(() => useOnlineDrive()) - // Act - First call act(() => { result.current.clearOnlineDriveData() }) const firstCallCount = mockDataSourceStoreState.setOnlineDriveFileList.mock.calls.length - // Rerender rerender() - // Act - Second call after rerender act(() => { result.current.clearOnlineDriveData() }) - // Assert - Callback should still work after rerender expect(mockDataSourceStoreState.setOnlineDriveFileList.mock.calls.length).toBe(firstCallCount + 1) }) }) }) -// ============================================================================ -// Preparation Component Tests -// ============================================================================ - describe('Preparation', () => { beforeEach(() => { vi.clearAllMocks() resetAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<Preparation />) - // Assert expect(screen.getByTestId('data-source-options')).toBeInTheDocument() }) it('should render StepIndicator', () => { - // Arrange & Act render(<Preparation />) - // Assert - Check for step text expect(screen.getByText('datasetPipeline.testRun.steps.dataSource')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.testRun.steps.documentProcessing')).toBeInTheDocument() }) it('should render DataSourceOptions on step 1', () => { - // Arrange & Act render(<Preparation />) - // Assert expect(screen.getByTestId('data-source-options')).toBeInTheDocument() }) it('should render Actions on step 1', () => { - // Arrange & Act render(<Preparation />) - // Assert expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument() }) it('should render FooterTips on step 1', () => { - // Arrange & Act render(<Preparation />) - // Assert expect(screen.getByText('datasetPipeline.testRun.tooltip')).toBeInTheDocument() }) it('should not render DocumentProcessing on step 1', () => { - // Arrange & Act render(<Preparation />) - // Assert expect(screen.queryByTestId('document-processing')).not.toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Data Source Selection Tests - // ------------------------------------------------------------------------- describe('Data Source Selection', () => { it('should render LocalFile component when local file datasource is selected', () => { - // Arrange render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) - // Assert expect(screen.getByTestId('local-file')).toBeInTheDocument() }) it('should render OnlineDocuments component when online document datasource is selected', () => { - // Arrange render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-document')) - // Assert expect(screen.getByTestId('online-documents')).toBeInTheDocument() }) it('should render WebsiteCrawl component when website crawl datasource is selected', () => { - // Arrange render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-website-crawl')) - // Assert expect(screen.getByTestId('website-crawl')).toBeInTheDocument() }) it('should render OnlineDrive component when online drive datasource is selected', () => { - // Arrange render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-drive')) - // Assert expect(screen.getByTestId('online-drive')).toBeInTheDocument() }) it('should pass correct props to LocalFile component', () => { - // Arrange render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) - // Assert const localFile = screen.getByTestId('local-file') expect(localFile).toHaveAttribute('data-extensions', '["txt","pdf"]') expect(localFile).toHaveAttribute('data-batch', 'false') }) it('should pass isInPipeline=true to OnlineDocuments', () => { - // Arrange render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-document')) - // Assert const onlineDocs = screen.getByTestId('online-documents') expect(onlineDocs).toHaveAttribute('data-in-pipeline', 'true') }) it('should pass supportBatchUpload=false to all data source components', () => { - // Arrange render(<Preparation />) - // Act - Select online document fireEvent.click(screen.getByTestId('select-online-document')) - // Assert expect(screen.getByTestId('online-documents')).toHaveAttribute('data-batch', 'false') }) it('should update dataSourceNodeId when selecting different datasources', () => { - // Arrange render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) - // Assert expect(screen.getByTestId('data-source-options')).toHaveAttribute('data-selected', 'local-file-node') - // Act - Select another fireEvent.click(screen.getByTestId('select-online-document')) - // Assert expect(screen.getByTestId('data-source-options')).toHaveAttribute('data-selected', 'online-doc-node') }) }) - // ------------------------------------------------------------------------- - // Next Button Disabled State Tests - // ------------------------------------------------------------------------- describe('Next Button Disabled State', () => { it('should disable next button when no datasource is selected', () => { - // Arrange & Act render(<Preparation />) - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() }) it('should disable next button for local file when file list is empty', () => { - // Arrange mockDataSourceStoreState.localFileList = [] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() }) it('should disable next button for local file when file has no id', () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: '', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() }) it('should enable next button for local file when file has valid id', () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) it('should disable next button for online document when documents list is empty', () => { - // Arrange mockDataSourceStoreState.onlineDocuments = [] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-document')) - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() }) it('should enable next button for online document when documents exist', () => { - // Arrange mockDataSourceStoreState.onlineDocuments = [{ workspace_id: 'ws-1', page_id: 'page-1' }] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-document')) - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) it('should disable next button for website crawl when pages list is empty', () => { - // Arrange mockDataSourceStoreState.websitePages = [] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-website-crawl')) - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() }) it('should enable next button for website crawl when pages exist', () => { - // Arrange mockDataSourceStoreState.websitePages = [{ url: 'https://example.com' }] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-website-crawl')) - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) it('should disable next button for online drive when no files selected', () => { - // Arrange mockDataSourceStoreState.selectedFileIds = [] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-drive')) - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() }) it('should enable next button for online drive when files are selected', () => { - // Arrange mockDataSourceStoreState.selectedFileIds = ['file-1'] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-drive')) - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) }) - // ------------------------------------------------------------------------- - // Step Navigation Tests - // ------------------------------------------------------------------------- describe('Step Navigation', () => { it('should navigate to step 2 when next button is clicked with valid data', () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] render(<Preparation />) - // Act - Select datasource and click next fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - // Assert expect(screen.getByTestId('document-processing')).toBeInTheDocument() expect(screen.queryByTestId('data-source-options')).not.toBeInTheDocument() }) it('should pass correct dataSourceNodeId to DocumentProcessing', () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - // Assert expect(screen.getByTestId('document-processing')).toHaveAttribute('data-node-id', 'local-file-node') }) it('should navigate back to step 1 when back button is clicked', () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] render(<Preparation />) - // Act - Go to step 2 fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) expect(screen.getByTestId('document-processing')).toBeInTheDocument() - // Act - Go back fireEvent.click(screen.getByTestId('back-btn')) - // Assert expect(screen.getByTestId('data-source-options')).toBeInTheDocument() expect(screen.queryByTestId('document-processing')).not.toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // handleProcess Tests - // ------------------------------------------------------------------------- describe('handleProcess', () => { it('should call handleRun with correct params for local file', async () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) fireEvent.click(screen.getByTestId('process-btn')) - // Assert await waitFor(() => { expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ inputs: { field1: 'value1' }, @@ -1711,17 +1319,14 @@ describe('Preparation', () => { }) it('should call handleRun with correct params for online document', async () => { - // Arrange mockDataSourceStoreState.onlineDocuments = [{ workspace_id: 'ws-1', page_id: 'page-1', title: 'Test Doc' }] mockDataSourceStoreState.currentCredentialId = 'cred-123' render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-document')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) fireEvent.click(screen.getByTestId('process-btn')) - // Assert await waitFor(() => { expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ inputs: { field1: 'value1' }, @@ -1732,17 +1337,14 @@ describe('Preparation', () => { }) it('should call handleRun with correct params for website crawl', async () => { - // Arrange mockDataSourceStoreState.websitePages = [{ url: 'https://example.com', title: 'Example' }] mockDataSourceStoreState.currentCredentialId = 'cred-456' render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-website-crawl')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) fireEvent.click(screen.getByTestId('process-btn')) - // Assert await waitFor(() => { expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ inputs: { field1: 'value1' }, @@ -1753,19 +1355,16 @@ describe('Preparation', () => { }) it('should call handleRun with correct params for online drive', async () => { - // Arrange mockDataSourceStoreState.selectedFileIds = ['file-1'] mockDataSourceStoreState.onlineDriveFileList = [{ id: 'file-1', name: 'data.csv', type: 'file' }] mockDataSourceStoreState.bucket = 'my-bucket' mockDataSourceStoreState.currentCredentialId = 'cred-789' render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-drive')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) fireEvent.click(screen.getByTestId('process-btn')) - // Assert await waitFor(() => { expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ inputs: { field1: 'value1' }, @@ -1776,211 +1375,151 @@ describe('Preparation', () => { }) it('should call setIsPreparingDataSource(false) after processing', async () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) fireEvent.click(screen.getByTestId('process-btn')) - // Assert await waitFor(() => { expect(mockWorkflowStoreState.setIsPreparingDataSource).toHaveBeenCalledWith(false) }) }) }) - // ------------------------------------------------------------------------- - // clearDataSourceData Tests - // ------------------------------------------------------------------------- describe('clearDataSourceData', () => { it('should clear online document data when switching from online document', () => { - // Arrange render(<Preparation />) - // Act - Select online document first fireEvent.click(screen.getByTestId('select-online-document')) - // Then switch to local file fireEvent.click(screen.getByTestId('select-local-file')) - // Assert expect(mockDataSourceStoreState.setDocumentsData).toHaveBeenCalled() expect(mockDataSourceStoreState.setOnlineDocuments).toHaveBeenCalled() }) it('should clear website crawl data when switching from website crawl', () => { - // Arrange render(<Preparation />) - // Act - Select website crawl first fireEvent.click(screen.getByTestId('select-website-crawl')) - // Then switch to local file fireEvent.click(screen.getByTestId('select-local-file')) - // Assert expect(mockDataSourceStoreState.setWebsitePages).toHaveBeenCalled() expect(mockDataSourceStoreState.setCrawlResult).toHaveBeenCalled() }) it('should clear online drive data when switching from online drive', () => { - // Arrange render(<Preparation />) - // Act - Select online drive first fireEvent.click(screen.getByTestId('select-online-drive')) - // Then switch to local file fireEvent.click(screen.getByTestId('select-local-file')) - // Assert expect(mockDataSourceStoreState.setOnlineDriveFileList).toHaveBeenCalled() expect(mockDataSourceStoreState.setBucket).toHaveBeenCalled() }) }) - // ------------------------------------------------------------------------- - // handleCredentialChange Tests - // ------------------------------------------------------------------------- describe('handleCredentialChange', () => { it('should update credential and clear data when credential changes for online document', () => { - // Arrange mockDataSourceStoreState.onlineDocuments = [{ workspace_id: 'ws-1' }] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-document')) fireEvent.click(screen.getByText('Change Credential')) - // Assert expect(mockDataSourceStoreState.setCurrentCredentialId).toHaveBeenCalledWith('new-credential-id') }) it('should clear data when credential changes for website crawl', () => { - // Arrange mockDataSourceStoreState.websitePages = [{ url: 'https://example.com' }] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-website-crawl')) fireEvent.click(screen.getByText('Change Credential')) - // Assert expect(mockDataSourceStoreState.setCurrentCredentialId).toHaveBeenCalledWith('new-credential-id') expect(mockDataSourceStoreState.setWebsitePages).toHaveBeenCalled() }) it('should clear data when credential changes for online drive', () => { - // Arrange mockDataSourceStoreState.selectedFileIds = ['file-1'] render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-online-drive')) fireEvent.click(screen.getByText('Change Credential')) - // Assert expect(mockDataSourceStoreState.setCurrentCredentialId).toHaveBeenCalledWith('new-credential-id') expect(mockDataSourceStoreState.setOnlineDriveFileList).toHaveBeenCalled() }) }) - // ------------------------------------------------------------------------- - // handleSwitchDataSource Tests - // ------------------------------------------------------------------------- describe('handleSwitchDataSource', () => { it('should clear credential when switching datasource', () => { - // Arrange render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) - // Assert expect(mockDataSourceStoreState.setCurrentCredentialId).toHaveBeenCalledWith('') }) it('should update currentNodeIdRef when switching datasource', () => { - // Arrange render(<Preparation />) - // Act fireEvent.click(screen.getByTestId('select-local-file')) - // Assert expect(mockDataSourceStoreState.currentNodeIdRef.current).toBe('local-file-node') }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange & Act const { rerender } = render(<Preparation />) rerender(<Preparation />) - // Assert expect(screen.getByTestId('data-source-options')).toBeInTheDocument() }) it('should maintain state across rerenders', () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] const { rerender } = render(<Preparation />) - // Act - Select datasource and go to step 2 fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - // Rerender rerender(<Preparation />) - // Assert - Should still be on step 2 expect(screen.getByTestId('document-processing')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle unmount cleanly', () => { - // Arrange const { unmount } = render(<Preparation />) - // Assert expect(() => unmount()).not.toThrow() }) it('should enable next button for unknown datasource type (return false branch)', () => { - // Arrange - This tests line 67: return false for unknown datasource types render(<Preparation />) - // Act - Select unknown type datasource fireEvent.click(screen.getByTestId('select-unknown-type')) - // Assert - Button should NOT be disabled because unknown type returns false (not disabled) expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) it('should handle handleProcess with unknown datasource type', async () => { - // Arrange - This tests processing with unknown type, triggering default branch render(<Preparation />) - // Act - Select unknown type and go to step 2 fireEvent.click(screen.getByTestId('select-unknown-type')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - // Process with unknown type fireEvent.click(screen.getByTestId('process-btn')) - // Assert - handleRun should be called with empty datasource_info_list (no type matched) await waitFor(() => { expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ start_node_id: 'unknown-type-node', @@ -1991,202 +1530,153 @@ describe('Preparation', () => { }) it('should handle rapid datasource switching', () => { - // Arrange render(<Preparation />) - // Act - Rapidly switch between datasources fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByTestId('select-online-document')) fireEvent.click(screen.getByTestId('select-website-crawl')) fireEvent.click(screen.getByTestId('select-online-drive')) fireEvent.click(screen.getByTestId('select-local-file')) - // Assert - Should end up with local file selected expect(screen.getByTestId('local-file')).toBeInTheDocument() }) it('should handle rapid step navigation', () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] render(<Preparation />) - // Act - Select and navigate fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) fireEvent.click(screen.getByTestId('back-btn')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) fireEvent.click(screen.getByTestId('back-btn')) - // Assert - Should be back on step 1 expect(screen.getByTestId('data-source-options')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Integration Tests - // ------------------------------------------------------------------------- describe('Integration', () => { it('should complete full flow: select datasource -> next -> process', async () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] render(<Preparation />) - // Act - Step 1: Select datasource fireEvent.click(screen.getByTestId('select-local-file')) expect(screen.getByTestId('local-file')).toBeInTheDocument() - // Act - Step 1: Click next fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) expect(screen.getByTestId('document-processing')).toBeInTheDocument() - // Act - Step 2: Process fireEvent.click(screen.getByTestId('process-btn')) - // Assert await waitFor(() => { expect(mockHandleRun).toHaveBeenCalled() }) }) it('should complete full flow with back navigation', async () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] mockDataSourceStoreState.onlineDocuments = [{ workspace_id: 'ws-1' }] render(<Preparation />) - // Act - Select local file and go to step 2 fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) expect(screen.getByTestId('document-processing')).toBeInTheDocument() - // Act - Go back and switch to online document fireEvent.click(screen.getByTestId('back-btn')) fireEvent.click(screen.getByTestId('select-online-document')) expect(screen.getByTestId('online-documents')).toBeInTheDocument() - // Act - Go to step 2 again fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - // Assert - Should be on step 2 with online document expect(screen.getByTestId('document-processing')).toHaveAttribute('data-node-id', 'online-doc-node') }) }) }) -// ============================================================================ -// Callback Dependencies Tests -// ============================================================================ - describe('Callback Dependencies', () => { beforeEach(() => { vi.clearAllMocks() resetAllMocks() }) - // ------------------------------------------------------------------------- - // nextBtnDisabled useMemo Dependencies - // ------------------------------------------------------------------------- describe('nextBtnDisabled Memoization', () => { it('should update when localFileList changes', () => { - // Arrange const { rerender } = render(<Preparation />) fireEvent.click(screen.getByTestId('select-local-file')) - // Assert - Initially disabled expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - // Act - Update localFileList mockDataSourceStoreState.localFileList = [ { file: { id: 'file-123', name: 'test.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] rerender(<Preparation />) fireEvent.click(screen.getByTestId('select-local-file')) - // Assert - Now enabled expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) it('should update when onlineDocuments changes', () => { - // Arrange const { rerender } = render(<Preparation />) fireEvent.click(screen.getByTestId('select-online-document')) - // Assert - Initially disabled expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - // Act - Update onlineDocuments mockDataSourceStoreState.onlineDocuments = [{ workspace_id: 'ws-1' }] rerender(<Preparation />) fireEvent.click(screen.getByTestId('select-online-document')) - // Assert - Now enabled expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) it('should update when websitePages changes', () => { - // Arrange const { rerender } = render(<Preparation />) fireEvent.click(screen.getByTestId('select-website-crawl')) - // Assert - Initially disabled expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - // Act - Update websitePages mockDataSourceStoreState.websitePages = [{ url: 'https://example.com' }] rerender(<Preparation />) fireEvent.click(screen.getByTestId('select-website-crawl')) - // Assert - Now enabled expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) it('should update when selectedFileIds changes', () => { - // Arrange const { rerender } = render(<Preparation />) fireEvent.click(screen.getByTestId('select-online-drive')) - // Assert - Initially disabled expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - // Act - Update selectedFileIds mockDataSourceStoreState.selectedFileIds = ['file-1'] rerender(<Preparation />) fireEvent.click(screen.getByTestId('select-online-drive')) - // Assert - Now enabled expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) }) - // ------------------------------------------------------------------------- - // handleProcess useCallback Dependencies - // ------------------------------------------------------------------------- describe('handleProcess Callback Dependencies', () => { it('should use latest store state when processing', async () => { - // Arrange mockDataSourceStoreState.localFileList = [ { file: { id: 'initial-file', name: 'initial.txt', type: 'text/plain', size: 100, extension: 'txt', mime_type: 'text/plain' } }, ] render(<Preparation />) - // Act - Select and navigate fireEvent.click(screen.getByTestId('select-local-file')) fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - // Update store before processing mockDataSourceStoreState.localFileList = [ { file: { id: 'updated-file', name: 'updated.txt', type: 'text/plain', size: 200, extension: 'txt', mime_type: 'text/plain' } }, ] fireEvent.click(screen.getByTestId('process-btn')) - // Assert - Should use latest file await waitFor(() => { expect(mockHandleRun).toHaveBeenCalledWith(expect.objectContaining({ datasource_info_list: expect.arrayContaining([ @@ -2197,24 +1687,16 @@ describe('Callback Dependencies', () => { }) }) - // ------------------------------------------------------------------------- - // clearDataSourceData useCallback Dependencies - // ------------------------------------------------------------------------- describe('clearDataSourceData Callback Dependencies', () => { it('should call correct clear function based on datasource type', () => { - // Arrange render(<Preparation />) - // Act - Select online document fireEvent.click(screen.getByTestId('select-online-document')) - // Assert expect(mockDataSourceStoreState.setOnlineDocuments).toHaveBeenCalled() - // Act - Switch to website crawl fireEvent.click(screen.getByTestId('select-website-crawl')) - // Assert expect(mockDataSourceStoreState.setWebsitePages).toHaveBeenCalled() }) }) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/__tests__/index.spec.tsx similarity index 74% rename from web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/__tests__/index.spec.tsx index 6899e4ac46..95f24d3b10 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/__tests__/index.spec.tsx @@ -1,49 +1,33 @@ import { fireEvent, render, screen } from '@testing-library/react' -import Actions from './index' - -// ============================================================================ -// Actions Component Tests -// ============================================================================ +import Actions from '../index' describe('Actions', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions handleNextStep={handleNextStep} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should render button with translated text', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions handleNextStep={handleNextStep} />) - // Assert - Translation mock returns key with namespace prefix expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument() }) it('should render with correct container structure', () => { - // Arrange const handleNextStep = vi.fn() - // Act const { container } = render(<Actions handleNextStep={handleNextStep} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper.className).toContain('flex') expect(wrapper.className).toContain('justify-end') @@ -52,197 +36,143 @@ describe('Actions', () => { }) it('should render span with px-0.5 class around text', () => { - // Arrange const handleNextStep = vi.fn() - // Act const { container } = render(<Actions handleNextStep={handleNextStep} />) - // Assert const span = container.querySelector('span') expect(span).toBeInTheDocument() expect(span?.className).toContain('px-0.5') }) }) - // ------------------------------------------------------------------------- - // Props Variations Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should pass disabled=true to button when disabled prop is true', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions disabled={true} handleNextStep={handleNextStep} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should pass disabled=false to button when disabled prop is false', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions disabled={false} handleNextStep={handleNextStep} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) it('should not disable button when disabled prop is undefined', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions handleNextStep={handleNextStep} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) it('should handle disabled switching from true to false', () => { - // Arrange const handleNextStep = vi.fn() - // Act const { rerender } = render( <Actions disabled={true} handleNextStep={handleNextStep} />, ) - // Assert - Initially disabled expect(screen.getByRole('button')).toBeDisabled() - // Act - Rerender with disabled=false rerender(<Actions disabled={false} handleNextStep={handleNextStep} />) - // Assert - Now enabled expect(screen.getByRole('button')).not.toBeDisabled() }) it('should handle disabled switching from false to true', () => { - // Arrange const handleNextStep = vi.fn() - // Act const { rerender } = render( <Actions disabled={false} handleNextStep={handleNextStep} />, ) - // Assert - Initially enabled expect(screen.getByRole('button')).not.toBeDisabled() - // Act - Rerender with disabled=true rerender(<Actions disabled={true} handleNextStep={handleNextStep} />) - // Assert - Now disabled expect(screen.getByRole('button')).toBeDisabled() }) it('should handle undefined disabled becoming true', () => { - // Arrange const handleNextStep = vi.fn() - // Act const { rerender } = render( <Actions handleNextStep={handleNextStep} />, ) - // Assert - Initially not disabled (undefined) expect(screen.getByRole('button')).not.toBeDisabled() - // Act - Rerender with disabled=true rerender(<Actions disabled={true} handleNextStep={handleNextStep} />) - // Assert - Now disabled expect(screen.getByRole('button')).toBeDisabled() }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call handleNextStep when button is clicked', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions handleNextStep={handleNextStep} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(handleNextStep).toHaveBeenCalledTimes(1) }) it('should call handleNextStep exactly once per click', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions handleNextStep={handleNextStep} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(handleNextStep).toHaveBeenCalled() expect(handleNextStep.mock.calls).toHaveLength(1) }) it('should call handleNextStep multiple times on multiple clicks', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions handleNextStep={handleNextStep} />) const button = screen.getByRole('button') fireEvent.click(button) fireEvent.click(button) fireEvent.click(button) - // Assert expect(handleNextStep).toHaveBeenCalledTimes(3) }) it('should not call handleNextStep when button is disabled and clicked', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions disabled={true} handleNextStep={handleNextStep} />) fireEvent.click(screen.getByRole('button')) - // Assert - Disabled button should not trigger onClick expect(handleNextStep).not.toHaveBeenCalled() }) it('should handle rapid clicks when not disabled', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions handleNextStep={handleNextStep} />) const button = screen.getByRole('button') - // Simulate rapid clicks for (let i = 0; i < 10; i++) fireEvent.click(button) - // Assert expect(handleNextStep).toHaveBeenCalledTimes(10) }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should use the new handleNextStep when prop changes', () => { - // Arrange const handleNextStep1 = vi.fn() const handleNextStep2 = vi.fn() - // Act const { rerender } = render( <Actions handleNextStep={handleNextStep1} />, ) @@ -251,16 +181,13 @@ describe('Actions', () => { rerender(<Actions handleNextStep={handleNextStep2} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(handleNextStep1).toHaveBeenCalledTimes(1) expect(handleNextStep2).toHaveBeenCalledTimes(1) }) it('should maintain functionality after rerender with same props', () => { - // Arrange const handleNextStep = vi.fn() - // Act const { rerender } = render( <Actions handleNextStep={handleNextStep} />, ) @@ -269,17 +196,14 @@ describe('Actions', () => { rerender(<Actions handleNextStep={handleNextStep} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(handleNextStep).toHaveBeenCalledTimes(2) }) it('should work correctly when handleNextStep changes multiple times', () => { - // Arrange const handleNextStep1 = vi.fn() const handleNextStep2 = vi.fn() const handleNextStep3 = vi.fn() - // Act const { rerender } = render( <Actions handleNextStep={handleNextStep1} />, ) @@ -291,77 +215,58 @@ describe('Actions', () => { rerender(<Actions handleNextStep={handleNextStep3} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(handleNextStep1).toHaveBeenCalledTimes(1) expect(handleNextStep2).toHaveBeenCalledTimes(1) expect(handleNextStep3).toHaveBeenCalledTimes(1) }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange const handleNextStep = vi.fn() - // Act - Verify component is memoized by checking display name pattern const { rerender } = render( <Actions handleNextStep={handleNextStep} />, ) - // Rerender with same props should work without issues rerender(<Actions handleNextStep={handleNextStep} />) - // Assert - Component should render correctly after rerender expect(screen.getByRole('button')).toBeInTheDocument() }) it('should not break when props remain the same across rerenders', () => { - // Arrange const handleNextStep = vi.fn() - // Act const { rerender } = render( <Actions disabled={false} handleNextStep={handleNextStep} />, ) - // Multiple rerenders with same props for (let i = 0; i < 5; i++) { rerender(<Actions disabled={false} handleNextStep={handleNextStep} />) } - // Assert - Should still function correctly fireEvent.click(screen.getByRole('button')) expect(handleNextStep).toHaveBeenCalledTimes(1) }) it('should update correctly when only disabled prop changes', () => { - // Arrange const handleNextStep = vi.fn() - // Act const { rerender } = render( <Actions disabled={false} handleNextStep={handleNextStep} />, ) - // Assert - Initially not disabled expect(screen.getByRole('button')).not.toBeDisabled() - // Act - Change only disabled prop rerender(<Actions disabled={true} handleNextStep={handleNextStep} />) - // Assert - Should reflect the new disabled state expect(screen.getByRole('button')).toBeDisabled() }) it('should update correctly when only handleNextStep prop changes', () => { - // Arrange const handleNextStep1 = vi.fn() const handleNextStep2 = vi.fn() - // Act const { rerender } = render( <Actions disabled={false} handleNextStep={handleNextStep1} />, ) @@ -369,169 +274,124 @@ describe('Actions', () => { fireEvent.click(screen.getByRole('button')) expect(handleNextStep1).toHaveBeenCalledTimes(1) - // Act - Change only handleNextStep prop rerender(<Actions disabled={false} handleNextStep={handleNextStep2} />) fireEvent.click(screen.getByRole('button')) - // Assert - New callback should be used expect(handleNextStep1).toHaveBeenCalledTimes(1) expect(handleNextStep2).toHaveBeenCalledTimes(1) }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should call handleNextStep even if it has side effects', () => { - // Arrange let sideEffectValue = 0 const handleNextStep = vi.fn(() => { sideEffectValue = 42 }) - // Act render(<Actions handleNextStep={handleNextStep} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(handleNextStep).toHaveBeenCalledTimes(1) expect(sideEffectValue).toBe(42) }) it('should handle handleNextStep that returns a value', () => { - // Arrange const handleNextStep = vi.fn(() => 'return value') - // Act render(<Actions handleNextStep={handleNextStep} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(handleNextStep).toHaveBeenCalledTimes(1) expect(handleNextStep).toHaveReturnedWith('return value') }) it('should handle handleNextStep that is async', async () => { - // Arrange const handleNextStep = vi.fn().mockResolvedValue(undefined) - // Act render(<Actions handleNextStep={handleNextStep} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(handleNextStep).toHaveBeenCalledTimes(1) }) it('should render correctly with both disabled=true and handleNextStep', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions disabled={true} handleNextStep={handleNextStep} />) - // Assert const button = screen.getByRole('button') expect(button).toBeDisabled() }) it('should handle component unmount gracefully', () => { - // Arrange const handleNextStep = vi.fn() - // Act const { unmount } = render(<Actions handleNextStep={handleNextStep} />) - // Assert - Unmount should not throw expect(() => unmount()).not.toThrow() }) it('should handle disabled as boolean-like falsy value', () => { - // Arrange const handleNextStep = vi.fn() - // Act - Test with explicit false render(<Actions disabled={false} handleNextStep={handleNextStep} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) }) - // ------------------------------------------------------------------------- - // Accessibility Tests - // ------------------------------------------------------------------------- describe('Accessibility', () => { it('should have button element that can receive focus', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions handleNextStep={handleNextStep} />) const button = screen.getByRole('button') - // Assert - Button should be focusable (not disabled by default) expect(button).not.toBeDisabled() }) it('should indicate disabled state correctly', () => { - // Arrange const handleNextStep = vi.fn() - // Act render(<Actions disabled={true} handleNextStep={handleNextStep} />) - // Assert expect(screen.getByRole('button')).toHaveAttribute('disabled') }) }) - // ------------------------------------------------------------------------- - // Integration Tests - // ------------------------------------------------------------------------- describe('Integration', () => { it('should work in a typical workflow: enable -> click -> disable', () => { - // Arrange const handleNextStep = vi.fn() - // Act - Start enabled const { rerender } = render( <Actions disabled={false} handleNextStep={handleNextStep} />, ) - // Assert - Can click when enabled expect(screen.getByRole('button')).not.toBeDisabled() fireEvent.click(screen.getByRole('button')) expect(handleNextStep).toHaveBeenCalledTimes(1) - // Act - Disable after click (simulating loading state) rerender(<Actions disabled={true} handleNextStep={handleNextStep} />) - // Assert - Cannot click when disabled expect(screen.getByRole('button')).toBeDisabled() fireEvent.click(screen.getByRole('button')) expect(handleNextStep).toHaveBeenCalledTimes(1) // Still 1, not 2 - // Act - Re-enable rerender(<Actions disabled={false} handleNextStep={handleNextStep} />) - // Assert - Can click again expect(screen.getByRole('button')).not.toBeDisabled() fireEvent.click(screen.getByRole('button')) expect(handleNextStep).toHaveBeenCalledTimes(2) }) it('should maintain consistent rendering across multiple state changes', () => { - // Arrange const handleNextStep = vi.fn() - // Act const { rerender } = render( <Actions disabled={false} handleNextStep={handleNextStep} />, ) - // Toggle disabled state multiple times const states = [true, false, true, false, true] states.forEach((disabled) => { rerender(<Actions disabled={disabled} handleNextStep={handleNextStep} />) @@ -541,7 +401,6 @@ describe('Actions', () => { expect(screen.getByRole('button')).not.toBeDisabled() }) - // Assert - Button should still render correctly expect(screen.getByRole('button')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument() }) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/index.spec.tsx similarity index 80% rename from web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/index.spec.tsx index a5e23d21a2..b159455cb6 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/index.spec.tsx @@ -1,28 +1,21 @@ -import type { DataSourceOption } from '../../types' +import type { DataSourceOption } from '../../../types' import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import DataSourceOptions from './index' -import OptionCard from './option-card' +import DataSourceOptions from '../index' +import OptionCard from '../option-card' -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Track mock options for useDatasourceOptions hook let mockDatasourceOptions: DataSourceOption[] = [] -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useDatasourceOptions: () => mockDatasourceOptions, })) -// Mock useToolIcon hook const mockToolIcon = { type: 'icon', icon: 'test-icon' } vi.mock('@/app/components/workflow/hooks', () => ({ useToolIcon: () => mockToolIcon, })) -// Mock BlockIcon component vi.mock('@/app/components/workflow/block-icon', () => ({ default: ({ type, toolIcon }: { type: string, toolIcon: unknown }) => ( <div data-testid="block-icon" data-type={type} data-tool-icon={JSON.stringify(toolIcon)}> @@ -31,10 +24,6 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ ), })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ title: 'Test Node', desc: 'Test description', @@ -55,24 +44,15 @@ const createDataSourceOption = (overrides?: Partial<DataSourceOption>): DataSour ...overrides, }) -// ============================================================================ -// OptionCard Component Tests -// ============================================================================ - describe('OptionCard', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render option card without crashing', () => { - // Arrange const nodeData = createNodeData() - // Act render( <OptionCard label="Test Label" @@ -82,15 +62,12 @@ describe('OptionCard', () => { />, ) - // Assert expect(screen.getByText('Test Label')).toBeInTheDocument() }) it('should render label text', () => { - // Arrange const nodeData = createNodeData() - // Act render( <OptionCard label="My Data Source" @@ -100,15 +77,12 @@ describe('OptionCard', () => { />, ) - // Assert expect(screen.getByText('My Data Source')).toBeInTheDocument() }) it('should render BlockIcon component', () => { - // Arrange const nodeData = createNodeData() - // Act render( <OptionCard label="Test" @@ -118,15 +92,12 @@ describe('OptionCard', () => { />, ) - // Assert expect(screen.getByTestId('block-icon')).toBeInTheDocument() }) it('should pass correct type to BlockIcon', () => { - // Arrange const nodeData = createNodeData() - // Act render( <OptionCard label="Test" @@ -136,17 +107,13 @@ describe('OptionCard', () => { />, ) - // Assert const blockIcon = screen.getByTestId('block-icon') - // BlockEnum.DataSource value is 'datasource' expect(blockIcon).toHaveAttribute('data-type', 'datasource') }) it('should set title attribute on label element', () => { - // Arrange const nodeData = createNodeData() - // Act render( <OptionCard label="Long Label Text" @@ -156,20 +123,14 @@ describe('OptionCard', () => { />, ) - // Assert expect(screen.getByTitle('Long Label Text')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Props Variations Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should apply selected styles when selected is true', () => { - // Arrange const nodeData = createNodeData() - // Act const { container } = render( <OptionCard label="Test" @@ -179,17 +140,14 @@ describe('OptionCard', () => { />, ) - // Assert const card = container.firstChild as HTMLElement expect(card.className).toContain('border-components-option-card-option-selected-border') expect(card.className).toContain('bg-components-option-card-option-selected-bg') }) it('should apply unselected styles when selected is false', () => { - // Arrange const nodeData = createNodeData() - // Act const { container } = render( <OptionCard label="Test" @@ -199,16 +157,13 @@ describe('OptionCard', () => { />, ) - // Assert const card = container.firstChild as HTMLElement expect(card.className).not.toContain('border-components-option-card-option-selected-border') }) it('should apply text-text-primary to label when selected', () => { - // Arrange const nodeData = createNodeData() - // Act render( <OptionCard label="Test Label" @@ -218,16 +173,13 @@ describe('OptionCard', () => { />, ) - // Assert const label = screen.getByText('Test Label') expect(label.className).toContain('text-text-primary') }) it('should apply text-text-secondary to label when not selected', () => { - // Arrange const nodeData = createNodeData() - // Act render( <OptionCard label="Test Label" @@ -237,16 +189,13 @@ describe('OptionCard', () => { />, ) - // Assert const label = screen.getByText('Test Label') expect(label.className).toContain('text-text-secondary') }) it('should handle undefined onClick prop', () => { - // Arrange const nodeData = createNodeData() - // Act const { container } = render( <OptionCard label="Test" @@ -257,19 +206,16 @@ describe('OptionCard', () => { />, ) - // Assert - should not throw when clicking const card = container.firstChild as HTMLElement expect(() => fireEvent.click(card)).not.toThrow() }) it('should handle different node data types', () => { - // Arrange const nodeData = createNodeData({ title: 'Website Crawler', provider_type: 'website_crawl', }) - // Act render( <OptionCard label="Website Crawler" @@ -279,21 +225,15 @@ describe('OptionCard', () => { />, ) - // Assert expect(screen.getByText('Website Crawler')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onClick with value when card is clicked', () => { - // Arrange const onClick = vi.fn() const nodeData = createNodeData() - // Act const { container } = render( <OptionCard label="Test" @@ -305,17 +245,14 @@ describe('OptionCard', () => { ) fireEvent.click(container.firstChild as HTMLElement) - // Assert expect(onClick).toHaveBeenCalledTimes(1) expect(onClick).toHaveBeenCalledWith('test-value') }) it('should call onClick with correct value for different cards', () => { - // Arrange const onClick = vi.fn() const nodeData = createNodeData() - // Act const { container: container1 } = render( <OptionCard label="Card 1" @@ -338,18 +275,15 @@ describe('OptionCard', () => { ) fireEvent.click(container2.firstChild as HTMLElement) - // Assert expect(onClick).toHaveBeenCalledTimes(2) expect(onClick).toHaveBeenNthCalledWith(1, 'value-1') expect(onClick).toHaveBeenNthCalledWith(2, 'value-2') }) it('should handle rapid clicks', () => { - // Arrange const onClick = vi.fn() const nodeData = createNodeData() - // Act const { container } = render( <OptionCard label="Test" @@ -364,16 +298,13 @@ describe('OptionCard', () => { fireEvent.click(card) fireEvent.click(card) - // Assert expect(onClick).toHaveBeenCalledTimes(3) }) it('should call onClick with empty string value', () => { - // Arrange const onClick = vi.fn() const nodeData = createNodeData() - // Act const { container } = render( <OptionCard label="Test" @@ -385,21 +316,15 @@ describe('OptionCard', () => { ) fireEvent.click(container.firstChild as HTMLElement) - // Assert expect(onClick).toHaveBeenCalledWith('') }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should maintain stable handleClickCard callback when props dont change', () => { - // Arrange const onClick = vi.fn() const nodeData = createNodeData() - // Act const { rerender, container } = render( <OptionCard label="Test" @@ -422,18 +347,15 @@ describe('OptionCard', () => { ) fireEvent.click(container.firstChild as HTMLElement) - // Assert expect(onClick).toHaveBeenCalledTimes(2) expect(onClick).toHaveBeenNthCalledWith(1, 'test-value') expect(onClick).toHaveBeenNthCalledWith(2, 'test-value') }) it('should update handleClickCard when value changes', () => { - // Arrange const onClick = vi.fn() const nodeData = createNodeData() - // Act const { rerender, container } = render( <OptionCard label="Test" @@ -456,18 +378,15 @@ describe('OptionCard', () => { ) fireEvent.click(container.firstChild as HTMLElement) - // Assert expect(onClick).toHaveBeenNthCalledWith(1, 'old-value') expect(onClick).toHaveBeenNthCalledWith(2, 'new-value') }) it('should update handleClickCard when onClick changes', () => { - // Arrange const onClick1 = vi.fn() const onClick2 = vi.fn() const nodeData = createNodeData() - // Act const { rerender, container } = render( <OptionCard label="Test" @@ -490,22 +409,16 @@ describe('OptionCard', () => { ) fireEvent.click(container.firstChild as HTMLElement) - // Assert expect(onClick1).toHaveBeenCalledTimes(1) expect(onClick2).toHaveBeenCalledTimes(1) }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized (React.memo)', () => { - // Arrange const onClick = vi.fn() const nodeData = createNodeData() - // Act const { rerender } = render( <OptionCard label="Test" @@ -516,7 +429,6 @@ describe('OptionCard', () => { />, ) - // Rerender with same props rerender( <OptionCard label="Test" @@ -527,15 +439,12 @@ describe('OptionCard', () => { />, ) - // Assert - Component should render without issues expect(screen.getByText('Test')).toBeInTheDocument() }) it('should re-render when selected prop changes', () => { - // Arrange const nodeData = createNodeData() - // Act const { rerender, container } = render( <OptionCard label="Test" @@ -557,21 +466,15 @@ describe('OptionCard', () => { />, ) - // Assert - Component should update styles card = container.firstChild as HTMLElement expect(card.className).toContain('border-components-option-card-option-selected-border') }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty label', () => { - // Arrange const nodeData = createNodeData() - // Act render( <OptionCard label="" @@ -581,16 +484,13 @@ describe('OptionCard', () => { />, ) - // Assert - Should render without crashing expect(screen.getByTestId('block-icon')).toBeInTheDocument() }) it('should handle very long label', () => { - // Arrange const nodeData = createNodeData() const longLabel = 'A'.repeat(200) - // Act render( <OptionCard label={longLabel} @@ -600,17 +500,14 @@ describe('OptionCard', () => { />, ) - // Assert expect(screen.getByText(longLabel)).toBeInTheDocument() expect(screen.getByTitle(longLabel)).toBeInTheDocument() }) it('should handle special characters in label', () => { - // Arrange const nodeData = createNodeData() const specialLabel = '<Test> & \'Label\' "Special"' - // Act render( <OptionCard label={specialLabel} @@ -620,15 +517,12 @@ describe('OptionCard', () => { />, ) - // Assert expect(screen.getByText(specialLabel)).toBeInTheDocument() }) it('should handle unicode characters in label', () => { - // Arrange const nodeData = createNodeData() - // Act render( <OptionCard label="æ•°æźæș 🎉 ăƒ‡ăƒŒă‚żă‚œăƒŒă‚č" @@ -638,16 +532,13 @@ describe('OptionCard', () => { />, ) - // Assert expect(screen.getByText('æ•°æźæș 🎉 ăƒ‡ăƒŒă‚żă‚œăƒŒă‚č')).toBeInTheDocument() }) it('should handle empty value', () => { - // Arrange const onClick = vi.fn() const nodeData = createNodeData() - // Act const { container } = render( <OptionCard label="Test" @@ -659,17 +550,14 @@ describe('OptionCard', () => { ) fireEvent.click(container.firstChild as HTMLElement) - // Assert expect(onClick).toHaveBeenCalledWith('') }) it('should handle special characters in value', () => { - // Arrange const onClick = vi.fn() const nodeData = createNodeData() const specialValue = 'test-value_123/abc:xyz' - // Act const { container } = render( <OptionCard label="Test" @@ -681,15 +569,12 @@ describe('OptionCard', () => { ) fireEvent.click(container.firstChild as HTMLElement) - // Assert expect(onClick).toHaveBeenCalledWith(specialValue) }) it('should handle nodeData with minimal properties', () => { - // Arrange const minimalNodeData = { title: 'Minimal' } as unknown as DataSourceNodeType - // Act render( <OptionCard label="Minimal" @@ -699,20 +584,14 @@ describe('OptionCard', () => { />, ) - // Assert expect(screen.getByText('Minimal')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Accessibility Tests - // ------------------------------------------------------------------------- describe('Accessibility', () => { it('should have cursor-pointer class for clickability indication', () => { - // Arrange const nodeData = createNodeData() - // Act const { container } = render( <OptionCard label="Test" @@ -722,17 +601,14 @@ describe('OptionCard', () => { />, ) - // Assert const card = container.firstChild as HTMLElement expect(card.className).toContain('cursor-pointer') }) it('should provide title attribute for label tooltip', () => { - // Arrange const nodeData = createNodeData() const label = 'This is a very long label that might get truncated' - // Act render( <OptionCard label={label} @@ -742,31 +618,21 @@ describe('OptionCard', () => { />, ) - // Assert expect(screen.getByTitle(label)).toBeInTheDocument() }) }) }) -// ============================================================================ -// DataSourceOptions Component Tests -// ============================================================================ - describe('DataSourceOptions', () => { beforeEach(() => { vi.clearAllMocks() mockDatasourceOptions = [] }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render container without crashing', () => { - // Arrange mockDatasourceOptions = [] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="" @@ -774,19 +640,16 @@ describe('DataSourceOptions', () => { />, ) - // Assert expect(container.querySelector('.grid')).toBeInTheDocument() }) it('should render OptionCard for each option', () => { - // Arrange mockDatasourceOptions = [ createDataSourceOption({ label: 'Option 1', value: 'opt-1' }), createDataSourceOption({ label: 'Option 2', value: 'opt-2' }), createDataSourceOption({ label: 'Option 3', value: 'opt-3' }), ] - // Act render( <DataSourceOptions dataSourceNodeId="" @@ -794,17 +657,14 @@ describe('DataSourceOptions', () => { />, ) - // Assert expect(screen.getByText('Option 1')).toBeInTheDocument() expect(screen.getByText('Option 2')).toBeInTheDocument() expect(screen.getByText('Option 3')).toBeInTheDocument() }) it('should render empty grid when no options', () => { - // Arrange mockDatasourceOptions = [] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="" @@ -812,17 +672,14 @@ describe('DataSourceOptions', () => { />, ) - // Assert const grid = container.querySelector('.grid') expect(grid).toBeInTheDocument() expect(grid?.children.length).toBe(0) }) it('should apply correct grid layout classes', () => { - // Arrange mockDatasourceOptions = [createDataSourceOption()] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="" @@ -830,7 +687,6 @@ describe('DataSourceOptions', () => { />, ) - // Assert const grid = container.querySelector('.grid') expect(grid?.className).toContain('grid-cols-4') expect(grid?.className).toContain('gap-1') @@ -838,13 +694,11 @@ describe('DataSourceOptions', () => { }) it('should render correct number of option cards', () => { - // Arrange mockDatasourceOptions = [ createDataSourceOption({ label: 'A', value: 'a' }), createDataSourceOption({ label: 'B', value: 'b' }), ] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="a" @@ -852,24 +706,18 @@ describe('DataSourceOptions', () => { />, ) - // Assert const cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards.length).toBe(2) }) }) - // ------------------------------------------------------------------------- - // Props Variations Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should mark correct option as selected based on dataSourceNodeId', () => { - // Arrange mockDatasourceOptions = [ createDataSourceOption({ label: 'Option 1', value: 'opt-1' }), createDataSourceOption({ label: 'Option 2', value: 'opt-2' }), ] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="opt-1" @@ -877,20 +725,17 @@ describe('DataSourceOptions', () => { />, ) - // Assert - First option should have selected styles const cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards[0].className).toContain('border-components-option-card-option-selected-border') expect(cards[1].className).not.toContain('border-components-option-card-option-selected-border') }) it('should mark second option as selected when matching', () => { - // Arrange mockDatasourceOptions = [ createDataSourceOption({ label: 'Option 1', value: 'opt-1' }), createDataSourceOption({ label: 'Option 2', value: 'opt-2' }), ] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="opt-2" @@ -898,20 +743,17 @@ describe('DataSourceOptions', () => { />, ) - // Assert const cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards[0].className).not.toContain('border-components-option-card-option-selected-border') expect(cards[1].className).toContain('border-components-option-card-option-selected-border') }) it('should mark none as selected when dataSourceNodeId does not match', () => { - // Arrange mockDatasourceOptions = [ createDataSourceOption({ label: 'Option 1', value: 'opt-1' }), createDataSourceOption({ label: 'Option 2', value: 'opt-2' }), ] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="non-existent" @@ -919,7 +761,6 @@ describe('DataSourceOptions', () => { />, ) - // Assert - No option should have selected styles const cards = container.querySelectorAll('.flex.cursor-pointer') cards.forEach((card) => { expect(card.className).not.toContain('border-components-option-card-option-selected-border') @@ -927,10 +768,8 @@ describe('DataSourceOptions', () => { }) it('should handle empty dataSourceNodeId', () => { - // Arrange mockDatasourceOptions = [createDataSourceOption()] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="" @@ -938,17 +777,12 @@ describe('DataSourceOptions', () => { />, ) - // Assert expect(container.querySelector('.grid')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onSelect with datasource when option is clicked', () => { - // Arrange const onSelect = vi.fn() const optionData = createNodeData({ title: 'Test Source' }) mockDatasourceOptions = [ @@ -959,7 +793,6 @@ describe('DataSourceOptions', () => { }), ] - // Act - Use a dataSourceNodeId to prevent auto-select on mount render( <DataSourceOptions dataSourceNodeId="test-id" @@ -968,7 +801,6 @@ describe('DataSourceOptions', () => { ) fireEvent.click(screen.getByText('Test Option')) - // Assert expect(onSelect).toHaveBeenCalledTimes(1) expect(onSelect).toHaveBeenCalledWith({ nodeId: 'test-id', @@ -977,7 +809,6 @@ describe('DataSourceOptions', () => { }) it('should call onSelect with correct option when different options are clicked', () => { - // Arrange const onSelect = vi.fn() const data1 = createNodeData({ title: 'Source 1' }) const data2 = createNodeData({ title: 'Source 2' }) @@ -986,7 +817,6 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Option 2', value: 'id-2', data: data2 }), ] - // Act - Use a dataSourceNodeId to prevent auto-select on mount render( <DataSourceOptions dataSourceNodeId="id-1" @@ -996,18 +826,15 @@ describe('DataSourceOptions', () => { fireEvent.click(screen.getByText('Option 1')) fireEvent.click(screen.getByText('Option 2')) - // Assert expect(onSelect).toHaveBeenCalledTimes(2) expect(onSelect).toHaveBeenNthCalledWith(1, { nodeId: 'id-1', nodeData: data1 }) expect(onSelect).toHaveBeenNthCalledWith(2, { nodeId: 'id-2', nodeData: data2 }) }) it('should not call onSelect when option value not found', () => { - // Arrange - This tests the early return in handleSelect const onSelect = vi.fn() mockDatasourceOptions = [] - // Act render( <DataSourceOptions dataSourceNodeId="" @@ -1015,19 +842,16 @@ describe('DataSourceOptions', () => { />, ) - // Assert - Since there are no options, onSelect should not be called expect(onSelect).not.toHaveBeenCalled() }) it('should handle clicking same option multiple times', () => { - // Arrange const onSelect = vi.fn() const optionData = createNodeData() mockDatasourceOptions = [ createDataSourceOption({ label: 'Option', value: 'opt-id', data: optionData }), ] - // Act render( <DataSourceOptions dataSourceNodeId="opt-id" @@ -1038,17 +862,12 @@ describe('DataSourceOptions', () => { fireEvent.click(screen.getByText('Option')) fireEvent.click(screen.getByText('Option')) - // Assert expect(onSelect).toHaveBeenCalledTimes(3) }) }) - // ------------------------------------------------------------------------- - // Side Effects and Cleanup Tests - // ------------------------------------------------------------------------- describe('Side Effects and Cleanup', () => { it('should auto-select first option on mount when dataSourceNodeId is empty', async () => { - // Arrange const onSelect = vi.fn() const firstOptionData = createNodeData({ title: 'First' }) mockDatasourceOptions = [ @@ -1056,7 +875,6 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Second', value: 'second-id' }), ] - // Act render( <DataSourceOptions dataSourceNodeId="" @@ -1064,7 +882,6 @@ describe('DataSourceOptions', () => { />, ) - // Assert - First option should be auto-selected on mount await waitFor(() => { expect(onSelect).toHaveBeenCalledWith({ nodeId: 'first-id', @@ -1074,14 +891,12 @@ describe('DataSourceOptions', () => { }) it('should not auto-select when dataSourceNodeId is provided', async () => { - // Arrange const onSelect = vi.fn() mockDatasourceOptions = [ createDataSourceOption({ label: 'First', value: 'first-id' }), createDataSourceOption({ label: 'Second', value: 'second-id' }), ] - // Act render( <DataSourceOptions dataSourceNodeId="second-id" @@ -1089,18 +904,15 @@ describe('DataSourceOptions', () => { />, ) - // Assert - onSelect should not be called since dataSourceNodeId is already set await waitFor(() => { expect(onSelect).not.toHaveBeenCalled() }) }) it('should not auto-select when options array is empty', async () => { - // Arrange const onSelect = vi.fn() mockDatasourceOptions = [] - // Act render( <DataSourceOptions dataSourceNodeId="" @@ -1108,20 +920,17 @@ describe('DataSourceOptions', () => { />, ) - // Assert await waitFor(() => { expect(onSelect).not.toHaveBeenCalled() }) }) it('should run effect only once on mount', async () => { - // Arrange const onSelect = vi.fn() mockDatasourceOptions = [ createDataSourceOption({ label: 'First', value: 'first-id' }), ] - // Act const { rerender } = render( <DataSourceOptions dataSourceNodeId="" @@ -1129,7 +938,6 @@ describe('DataSourceOptions', () => { />, ) - // Rerender multiple times rerender( <DataSourceOptions dataSourceNodeId="" @@ -1143,21 +951,18 @@ describe('DataSourceOptions', () => { />, ) - // Assert - Effect should only run once (on mount) await waitFor(() => { expect(onSelect).toHaveBeenCalledTimes(1) }) }) it('should not re-run effect on rerender with different props', async () => { - // Arrange const onSelect1 = vi.fn() const onSelect2 = vi.fn() mockDatasourceOptions = [ createDataSourceOption({ label: 'First', value: 'first-id' }), ] - // Act const { rerender } = render( <DataSourceOptions dataSourceNodeId="" @@ -1176,18 +981,15 @@ describe('DataSourceOptions', () => { />, ) - // Assert - onSelect2 should not be called from effect expect(onSelect2).not.toHaveBeenCalled() }) it('should handle unmount cleanly', () => { - // Arrange const onSelect = vi.fn() mockDatasourceOptions = [ createDataSourceOption({ label: 'Test', value: 'test-id' }), ] - // Act const { unmount } = render( <DataSourceOptions dataSourceNodeId="test-id" @@ -1195,24 +997,18 @@ describe('DataSourceOptions', () => { />, ) - // Assert - Should unmount without errors expect(() => unmount()).not.toThrow() }) }) - // ------------------------------------------------------------------------- - // Callback Stability Tests - // ------------------------------------------------------------------------- describe('Callback Stability', () => { it('should maintain stable handleSelect callback', () => { - // Arrange const onSelect = vi.fn() const optionData = createNodeData() mockDatasourceOptions = [ createDataSourceOption({ label: 'Option', value: 'opt-id', data: optionData }), ] - // Act const { rerender } = render( <DataSourceOptions dataSourceNodeId="" @@ -1229,19 +1025,16 @@ describe('DataSourceOptions', () => { ) fireEvent.click(screen.getByText('Option')) - // Assert expect(onSelect).toHaveBeenCalledTimes(3) // 1 auto-select + 2 clicks }) it('should update handleSelect when onSelect prop changes', () => { - // Arrange const onSelect1 = vi.fn() const onSelect2 = vi.fn() mockDatasourceOptions = [ createDataSourceOption({ label: 'Option', value: 'opt-id' }), ] - // Act const { rerender } = render( <DataSourceOptions dataSourceNodeId="opt-id" @@ -1258,13 +1051,11 @@ describe('DataSourceOptions', () => { ) fireEvent.click(screen.getByText('Option')) - // Assert expect(onSelect1).toHaveBeenCalledTimes(1) expect(onSelect2).toHaveBeenCalledTimes(1) }) it('should update handleSelect when options change', () => { - // Arrange const onSelect = vi.fn() const data1 = createNodeData({ title: 'Data 1' }) const data2 = createNodeData({ title: 'Data 2' }) @@ -1272,7 +1063,6 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Option', value: 'opt-id', data: data1 }), ] - // Act const { rerender } = render( <DataSourceOptions dataSourceNodeId="opt-id" @@ -1281,7 +1071,6 @@ describe('DataSourceOptions', () => { ) fireEvent.click(screen.getByText('Option')) - // Update options with different data mockDatasourceOptions = [ createDataSourceOption({ label: 'Option', value: 'opt-id', data: data2 }), ] @@ -1293,24 +1082,18 @@ describe('DataSourceOptions', () => { ) fireEvent.click(screen.getByText('Option')) - // Assert expect(onSelect).toHaveBeenNthCalledWith(1, { nodeId: 'opt-id', nodeData: data1 }) expect(onSelect).toHaveBeenNthCalledWith(2, { nodeId: 'opt-id', nodeData: data2 }) }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle single option', () => { - // Arrange const onSelect = vi.fn() mockDatasourceOptions = [ createDataSourceOption({ label: 'Only Option', value: 'only-id' }), ] - // Act render( <DataSourceOptions dataSourceNodeId="only-id" @@ -1318,16 +1101,13 @@ describe('DataSourceOptions', () => { />, ) - // Assert expect(screen.getByText('Only Option')).toBeInTheDocument() }) it('should handle many options', () => { - // Arrange mockDatasourceOptions = Array.from({ length: 20 }, (_, i) => createDataSourceOption({ label: `Option ${i}`, value: `opt-${i}` })) - // Act render( <DataSourceOptions dataSourceNodeId="" @@ -1335,13 +1115,11 @@ describe('DataSourceOptions', () => { />, ) - // Assert expect(screen.getByText('Option 0')).toBeInTheDocument() expect(screen.getByText('Option 19')).toBeInTheDocument() }) it('should handle options with duplicate labels but different values', () => { - // Arrange const onSelect = vi.fn() const data1 = createNodeData({ title: 'Source 1' }) const data2 = createNodeData({ title: 'Source 2' }) @@ -1350,7 +1128,6 @@ describe('DataSourceOptions', () => { createDataSourceOption({ label: 'Same Label', value: 'id-2', data: data2 }), ] - // Act render( <DataSourceOptions dataSourceNodeId="" @@ -1360,12 +1137,10 @@ describe('DataSourceOptions', () => { const labels = screen.getAllByText('Same Label') fireEvent.click(labels[1]) // Click second one - // Assert expect(onSelect).toHaveBeenLastCalledWith({ nodeId: 'id-2', nodeData: data2 }) }) it('should handle special characters in option values', () => { - // Arrange const onSelect = vi.fn() const specialData = createNodeData() mockDatasourceOptions = [ @@ -1376,7 +1151,6 @@ describe('DataSourceOptions', () => { }), ] - // Act render( <DataSourceOptions dataSourceNodeId="" @@ -1385,7 +1159,6 @@ describe('DataSourceOptions', () => { ) fireEvent.click(screen.getByText('Special')) - // Assert expect(onSelect).toHaveBeenCalledWith({ nodeId: 'special-chars_123-abc', nodeData: specialData, @@ -1393,13 +1166,9 @@ describe('DataSourceOptions', () => { }) it('should handle click on non-existent option value gracefully', () => { - // Arrange - Test the early return in handleSelect when selectedOption is not found - // This is a bit tricky to test directly since options are rendered from the same array - // We'll test by verifying the component doesn't crash with empty options const onSelect = vi.fn() mockDatasourceOptions = [] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="" @@ -1407,19 +1176,16 @@ describe('DataSourceOptions', () => { />, ) - // Assert - No options to click, but component should render expect(container.querySelector('.grid')).toBeInTheDocument() }) it('should handle options with empty string values', () => { - // Arrange const onSelect = vi.fn() const emptyValueData = createNodeData() mockDatasourceOptions = [ createDataSourceOption({ label: 'Empty Value', value: '', data: emptyValueData }), ] - // Act render( <DataSourceOptions dataSourceNodeId="" @@ -1428,7 +1194,6 @@ describe('DataSourceOptions', () => { ) fireEvent.click(screen.getByText('Empty Value')) - // Assert - Should call onSelect with empty string nodeId expect(onSelect).toHaveBeenCalledWith({ nodeId: '', nodeData: emptyValueData, @@ -1436,12 +1201,10 @@ describe('DataSourceOptions', () => { }) it('should handle options with whitespace-only labels', () => { - // Arrange mockDatasourceOptions = [ createDataSourceOption({ label: ' ', value: 'whitespace' }), ] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="whitespace" @@ -1449,25 +1212,19 @@ describe('DataSourceOptions', () => { />, ) - // Assert const cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards.length).toBe(1) }) }) - // ------------------------------------------------------------------------- - // Error Handling Tests - // ------------------------------------------------------------------------- describe('Error Handling', () => { it('should not crash when nodeData has unexpected shape', () => { - // Arrange const onSelect = vi.fn() const weirdNodeData = { unexpected: 'data' } as unknown as DataSourceNodeType mockDatasourceOptions = [ createDataSourceOption({ label: 'Weird', value: 'weird-id', data: weirdNodeData }), ] - // Act render( <DataSourceOptions dataSourceNodeId="weird-id" @@ -1476,7 +1233,6 @@ describe('DataSourceOptions', () => { ) fireEvent.click(screen.getByText('Weird')) - // Assert expect(onSelect).toHaveBeenCalledWith({ nodeId: 'weird-id', nodeData: weirdNodeData, @@ -1485,22 +1241,14 @@ describe('DataSourceOptions', () => { }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('DataSourceOptions Integration', () => { beforeEach(() => { vi.clearAllMocks() mockDatasourceOptions = [] }) - // ------------------------------------------------------------------------- - // Full Flow Tests - // ------------------------------------------------------------------------- describe('Full Flow', () => { it('should complete full selection flow: render -> auto-select -> manual select', async () => { - // Arrange const onSelect = vi.fn() const data1 = createNodeData({ title: 'Source 1' }) const data2 = createNodeData({ title: 'Source 2' }) @@ -1509,7 +1257,6 @@ describe('DataSourceOptions Integration', () => { createDataSourceOption({ label: 'Option 2', value: 'id-2', data: data2 }), ] - // Act render( <DataSourceOptions dataSourceNodeId="" @@ -1517,20 +1264,16 @@ describe('DataSourceOptions Integration', () => { />, ) - // Assert - Auto-select first option on mount await waitFor(() => { expect(onSelect).toHaveBeenCalledWith({ nodeId: 'id-1', nodeData: data1 }) }) - // Act - Manual select second option fireEvent.click(screen.getByText('Option 2')) - // Assert expect(onSelect).toHaveBeenLastCalledWith({ nodeId: 'id-2', nodeData: data2 }) }) it('should update selection state when clicking different options', () => { - // Arrange const onSelect = vi.fn() mockDatasourceOptions = [ createDataSourceOption({ label: 'Option A', value: 'a' }), @@ -1538,7 +1281,6 @@ describe('DataSourceOptions Integration', () => { createDataSourceOption({ label: 'Option C', value: 'c' }), ] - // Act - Start with Option B selected const { rerender, container } = render( <DataSourceOptions dataSourceNodeId="b" @@ -1546,11 +1288,9 @@ describe('DataSourceOptions Integration', () => { />, ) - // Assert - Option B should be selected let cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards[1].className).toContain('border-components-option-card-option-selected-border') - // Act - Simulate selection change to Option C rerender( <DataSourceOptions dataSourceNodeId="c" @@ -1558,14 +1298,12 @@ describe('DataSourceOptions Integration', () => { />, ) - // Assert - Option C should now be selected cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards[2].className).toContain('border-components-option-card-option-selected-border') expect(cards[1].className).not.toContain('border-components-option-card-option-selected-border') }) it('should handle rapid option switching', async () => { - // Arrange const onSelect = vi.fn() mockDatasourceOptions = [ createDataSourceOption({ label: 'A', value: 'a' }), @@ -1573,7 +1311,6 @@ describe('DataSourceOptions Integration', () => { createDataSourceOption({ label: 'C', value: 'c' }), ] - // Act render( <DataSourceOptions dataSourceNodeId="a" @@ -1586,17 +1323,12 @@ describe('DataSourceOptions Integration', () => { fireEvent.click(screen.getByText('A')) fireEvent.click(screen.getByText('B')) - // Assert expect(onSelect).toHaveBeenCalledTimes(4) }) }) - // ------------------------------------------------------------------------- - // Component Communication Tests - // ------------------------------------------------------------------------- describe('Component Communication', () => { it('should pass correct props from DataSourceOptions to OptionCard', () => { - // Arrange mockDatasourceOptions = [ createDataSourceOption({ label: 'Test Label', @@ -1605,7 +1337,6 @@ describe('DataSourceOptions Integration', () => { }), ] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="test-value" @@ -1613,7 +1344,6 @@ describe('DataSourceOptions Integration', () => { />, ) - // Assert - Verify OptionCard receives correct props through rendered output expect(screen.getByText('Test Label')).toBeInTheDocument() expect(screen.getByTestId('block-icon')).toBeInTheDocument() const card = container.querySelector('.flex.cursor-pointer') @@ -1621,14 +1351,12 @@ describe('DataSourceOptions Integration', () => { }) it('should propagate click events from OptionCard to DataSourceOptions', () => { - // Arrange const onSelect = vi.fn() const nodeData = createNodeData() mockDatasourceOptions = [ createDataSourceOption({ label: 'Click Me', value: 'click-id', data: nodeData }), ] - // Act render( <DataSourceOptions dataSourceNodeId="click-id" @@ -1637,7 +1365,6 @@ describe('DataSourceOptions Integration', () => { ) fireEvent.click(screen.getByText('Click Me')) - // Assert expect(onSelect).toHaveBeenCalledWith({ nodeId: 'click-id', nodeData, @@ -1645,19 +1372,14 @@ describe('DataSourceOptions Integration', () => { }) }) - // ------------------------------------------------------------------------- - // State Consistency Tests - // ------------------------------------------------------------------------- describe('State Consistency', () => { it('should maintain consistent selection across multiple renders', () => { - // Arrange const onSelect = vi.fn() mockDatasourceOptions = [ createDataSourceOption({ label: 'A', value: 'a' }), createDataSourceOption({ label: 'B', value: 'b' }), ] - // Act const { rerender, container } = render( <DataSourceOptions dataSourceNodeId="a" @@ -1665,7 +1387,6 @@ describe('DataSourceOptions Integration', () => { />, ) - // Multiple rerenders for (let i = 0; i < 5; i++) { rerender( <DataSourceOptions @@ -1675,14 +1396,12 @@ describe('DataSourceOptions Integration', () => { ) } - // Assert - Selection should remain consistent const cards = container.querySelectorAll('.flex.cursor-pointer') expect(cards[0].className).toContain('border-components-option-card-option-selected-border') expect(cards[1].className).not.toContain('border-components-option-card-option-selected-border') }) it('should handle options array reference change with same content', () => { - // Arrange const onSelect = vi.fn() const nodeData = createNodeData() @@ -1690,7 +1409,6 @@ describe('DataSourceOptions Integration', () => { createDataSourceOption({ label: 'Option', value: 'opt', data: nodeData }), ] - // Act const { rerender } = render( <DataSourceOptions dataSourceNodeId="opt" @@ -1698,7 +1416,6 @@ describe('DataSourceOptions Integration', () => { />, ) - // Create new array reference with same content mockDatasourceOptions = [ createDataSourceOption({ label: 'Option', value: 'opt', data: nodeData }), ] @@ -1712,7 +1429,6 @@ describe('DataSourceOptions Integration', () => { fireEvent.click(screen.getByText('Option')) - // Assert - Should still work correctly expect(onSelect).toHaveBeenCalledWith({ nodeId: 'opt', nodeData, @@ -1721,10 +1437,6 @@ describe('DataSourceOptions Integration', () => { }) }) -// ============================================================================ -// handleSelect Early Return Branch Coverage -// ============================================================================ - describe('handleSelect Early Return Coverage', () => { beforeEach(() => { vi.clearAllMocks() @@ -1732,9 +1444,6 @@ describe('handleSelect Early Return Coverage', () => { }) it('should test early return when option not found by using modified mock during click', () => { - // Arrange - Test strategy: We need to trigger the early return when - // selectedOption is not found. Since the component renders cards from - // the options array, we need to modify the mock between render and click. const onSelect = vi.fn() const originalOptions = [ createDataSourceOption({ label: 'Option A', value: 'a' }), @@ -1742,7 +1451,6 @@ describe('handleSelect Early Return Coverage', () => { ] mockDatasourceOptions = originalOptions - // Act - Render the component const { rerender } = render( <DataSourceOptions dataSourceNodeId="a" @@ -1750,12 +1458,6 @@ describe('handleSelect Early Return Coverage', () => { />, ) - // Now we need to cause the handleSelect to not find the option. - // The callback is memoized with [onSelect, options], so if we change - // the options, the callback should be updated too. - - // Let's create a scenario where the value doesn't match any option - // by rendering with options that have different values const newOptions = [ createDataSourceOption({ label: 'Option A', value: 'x' }), // Changed from 'a' to 'x' createDataSourceOption({ label: 'Option B', value: 'y' }), // Changed from 'b' to 'y' @@ -1769,11 +1471,8 @@ describe('handleSelect Early Return Coverage', () => { />, ) - // Click on 'Option A' which now has value 'x', not 'a' - // Since we're selecting by text, this tests that the click works fireEvent.click(screen.getByText('Option A')) - // Assert - onSelect should be called with the new value 'x' expect(onSelect).toHaveBeenCalledWith({ nodeId: 'x', nodeData: expect.any(Object), @@ -1781,11 +1480,9 @@ describe('handleSelect Early Return Coverage', () => { }) it('should handle empty options array gracefully', () => { - // Arrange - Edge case: empty options const onSelect = vi.fn() mockDatasourceOptions = [] - // Act const { container } = render( <DataSourceOptions dataSourceNodeId="" @@ -1793,13 +1490,11 @@ describe('handleSelect Early Return Coverage', () => { />, ) - // Assert - No options to click, onSelect not called expect(container.querySelector('.grid')).toBeInTheDocument() expect(onSelect).not.toHaveBeenCalled() }) it('should handle auto-select with mismatched first option', async () => { - // Arrange - Test auto-select behavior const onSelect = vi.fn() const firstOptionData = createNodeData({ title: 'First' }) mockDatasourceOptions = [ @@ -1810,7 +1505,6 @@ describe('handleSelect Early Return Coverage', () => { }), ] - // Act - Empty dataSourceNodeId triggers auto-select render( <DataSourceOptions dataSourceNodeId="" @@ -1818,7 +1512,6 @@ describe('handleSelect Early Return Coverage', () => { />, ) - // Assert - First option auto-selected await waitFor(() => { expect(onSelect).toHaveBeenCalledWith({ nodeId: 'first-value', diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx similarity index 85% rename from web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx index f69347d038..341dfe2a51 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx @@ -8,15 +8,10 @@ import * as React from 'react' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { PipelineInputVarType } from '@/models/pipeline' -import Actions from './actions' -import DocumentProcessing from './index' -import Options from './options' +import Actions from '../actions' +import DocumentProcessing from '../index' +import Options from '../options' -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock workflow store let mockPipelineId: string | null = 'test-pipeline-id' let mockWorkflowRunningData: { result: { status: string } } | undefined @@ -35,7 +30,6 @@ vi.mock('@/app/components/workflow/store', () => ({ }, })) -// Mock useDraftPipelineProcessingParams let mockParamsConfig: PipelineProcessingParamsResponse | undefined let mockIsFetchingParams = false @@ -46,7 +40,6 @@ vi.mock('@/service/use-pipeline', () => ({ }), })) -// Mock use-input-fields hooks const mockUseInitialData = vi.fn() const mockUseConfigurations = vi.fn() @@ -55,14 +48,12 @@ vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ useConfigurations: (variables: RAGPipelineVariables) => mockUseConfigurations(variables), })) -// Mock generateZodSchema const mockGenerateZodSchema = vi.fn() vi.mock('@/app/components/base/form/form-scenarios/base/utils', () => ({ generateZodSchema: (configurations: BaseConfiguration[]) => mockGenerateZodSchema(configurations), })) -// Mock Toast const mockToastNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ @@ -71,7 +62,6 @@ vi.mock('@/app/components/base/toast', () => ({ }, })) -// Mock useAppForm const mockHandleSubmit = vi.fn() const mockFormStore = { isSubmitting: false, @@ -112,7 +102,6 @@ vi.mock('@/app/components/base/form', () => ({ }, })) -// Mock BaseField vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ default: ({ config }: { initialData: Record<string, unknown>, config: BaseConfiguration }) => { return () => ( @@ -125,10 +114,6 @@ vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ }, })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createRAGPipelineVariable = (overrides?: Partial<RAGPipelineVariable>): RAGPipelineVariable => ({ belong_to_node_id: 'test-node', type: PipelineInputVarType.textInput, @@ -163,10 +148,6 @@ const createMockSchema = (): ZodSchema => ({ safeParse: vi.fn().mockReturnValue({ success: true }), }) as unknown as ZodSchema -// ============================================================================ -// Helper Functions -// ============================================================================ - const setupMocks = (options?: { pipelineId?: string | null paramsConfig?: PipelineProcessingParamsResponse @@ -201,10 +182,6 @@ const renderWithQueryClient = (component: React.ReactElement) => { ) } -// ============================================================================ -// DocumentProcessing Component Tests -// ============================================================================ - describe('DocumentProcessing', () => { const defaultProps = { dataSourceNodeId: 'datasource-node-1', @@ -217,101 +194,75 @@ describe('DocumentProcessing', () => { setupMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange setupMocks({ configurations: [createBaseConfiguration()], }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) it('should render Options component with form elements', () => { - // Arrange const configurations = [ createBaseConfiguration({ variable: 'field1', label: 'Field 1' }), createBaseConfiguration({ variable: 'field2', label: 'Field 2' }), ] setupMocks({ configurations }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert expect(screen.getByTestId('field-field1')).toBeInTheDocument() expect(screen.getByTestId('field-field2')).toBeInTheDocument() }) it('should render no fields when configurations is empty', () => { - // Arrange setupMocks({ configurations: [] }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument() }) it('should call useInitialData with variables from paramsConfig', () => { - // Arrange const variables = [createRAGPipelineVariable({ variable: 'var1' })] setupMocks({ paramsConfig: { variables }, }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert expect(mockUseInitialData).toHaveBeenCalledWith(variables) }) it('should call useConfigurations with variables from paramsConfig', () => { - // Arrange const variables = [createRAGPipelineVariable({ variable: 'var1' })] setupMocks({ paramsConfig: { variables }, }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert expect(mockUseConfigurations).toHaveBeenCalledWith(variables) }) it('should use empty array when paramsConfig.variables is undefined', () => { - // Arrange setupMocks({ paramsConfig: undefined }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert expect(mockUseInitialData).toHaveBeenCalledWith([]) expect(mockUseConfigurations).toHaveBeenCalledWith([]) }) }) - // ------------------------------------------------------------------------- - // Props Testing - // ------------------------------------------------------------------------- describe('Props Testing', () => { it('should pass dataSourceNodeId to useInputVariables hook', () => { - // Arrange const customNodeId = 'custom-datasource-node' setupMocks() - // Act renderWithQueryClient( <DocumentProcessing {...defaultProps} @@ -319,16 +270,13 @@ describe('DocumentProcessing', () => { />, ) - // Assert - verify hook is called (mocked, so we check component renders) expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) it('should pass onProcess callback to Options component', () => { - // Arrange const mockOnProcess = vi.fn() setupMocks({ configurations: [] }) - // Act const { container } = renderWithQueryClient( <DocumentProcessing {...defaultProps} @@ -336,16 +284,13 @@ describe('DocumentProcessing', () => { />, ) - // Assert - form should be rendered expect(container.querySelector('form')).toBeInTheDocument() }) it('should pass onBack callback to Actions component', () => { - // Arrange const mockOnBack = vi.fn() setupMocks() - // Act renderWithQueryClient( <DocumentProcessing {...defaultProps} @@ -353,37 +298,28 @@ describe('DocumentProcessing', () => { />, ) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Callback Stability and Memoization Tests - // ------------------------------------------------------------------------- describe('Callback Stability and Memoization', () => { it('should memoize renderCustomActions callback', () => { - // Arrange setupMocks() const { rerender } = renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Act - rerender with same props rerender( <QueryClientProvider client={createQueryClient()}> <DocumentProcessing {...defaultProps} /> </QueryClientProvider>, ) - // Assert - component should render correctly without issues expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) it('should update renderCustomActions when isFetchingParams changes', () => { - // Arrange setupMocks({ isFetchingParams: false }) const { rerender } = renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Act setupMocks({ isFetchingParams: true }) rerender( <QueryClientProvider client={createQueryClient()}> @@ -391,12 +327,10 @@ describe('DocumentProcessing', () => { </QueryClientProvider>, ) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) it('should update renderCustomActions when onBack changes', () => { - // Arrange const onBack1 = vi.fn() const onBack2 = vi.fn() setupMocks() @@ -404,28 +338,21 @@ describe('DocumentProcessing', () => { <DocumentProcessing {...defaultProps} onBack={onBack1} />, ) - // Act rerender( <QueryClientProvider client={createQueryClient()}> <DocumentProcessing {...defaultProps} onBack={onBack2} /> </QueryClientProvider>, ) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // User Interactions Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onBack when back button is clicked', () => { - // Arrange const mockOnBack = vi.fn() setupMocks() - // Act renderWithQueryClient( <DocumentProcessing {...defaultProps} @@ -435,16 +362,13 @@ describe('DocumentProcessing', () => { const backButton = screen.getByText('datasetPipeline.operations.backToDataSource') fireEvent.click(backButton) - // Assert expect(mockOnBack).toHaveBeenCalledTimes(1) }) it('should handle form submission', () => { - // Arrange const mockOnProcess = vi.fn() setupMocks() - // Act renderWithQueryClient( <DocumentProcessing {...defaultProps} @@ -454,33 +378,25 @@ describe('DocumentProcessing', () => { const processButton = screen.getByText('datasetPipeline.operations.process') fireEvent.click(processButton) - // Assert expect(mockHandleSubmit).toHaveBeenCalled() }) }) - // ------------------------------------------------------------------------- - // Component Memoization Tests - // ------------------------------------------------------------------------- describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange setupMocks() const { rerender } = renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Act - rerender with same props rerender( <QueryClientProvider client={createQueryClient()}> <DocumentProcessing {...defaultProps} /> </QueryClientProvider>, ) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) it('should not break when re-rendering with different props', () => { - // Arrange const initialProps = { ...defaultProps, dataSourceNodeId: 'node-1', @@ -488,7 +404,6 @@ describe('DocumentProcessing', () => { setupMocks() const { rerender } = renderWithQueryClient(<DocumentProcessing {...initialProps} />) - // Act const newProps = { ...defaultProps, dataSourceNodeId: 'node-2', @@ -499,52 +414,38 @@ describe('DocumentProcessing', () => { </QueryClientProvider>, ) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle undefined paramsConfig', () => { - // Arrange setupMocks({ paramsConfig: undefined }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert expect(mockUseInitialData).toHaveBeenCalledWith([]) expect(mockUseConfigurations).toHaveBeenCalledWith([]) }) it('should handle paramsConfig with empty variables', () => { - // Arrange setupMocks({ paramsConfig: { variables: [] } }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert expect(mockUseInitialData).toHaveBeenCalledWith([]) expect(mockUseConfigurations).toHaveBeenCalledWith([]) }) it('should handle null pipelineId', () => { - // Arrange setupMocks({ pipelineId: null }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) it('should handle large number of variables', () => { - // Arrange const variables = Array.from({ length: 50 }, (_, i) => createRAGPipelineVariable({ variable: `var_${i}` })) const configurations = Array.from({ length: 50 }, (_, i) => @@ -554,18 +455,14 @@ describe('DocumentProcessing', () => { configurations, }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert expect(screen.getAllByTestId(/^field-var_/)).toHaveLength(50) }) it('should handle special characters in node id', () => { - // Arrange setupMocks() - // Act renderWithQueryClient( <DocumentProcessing {...defaultProps} @@ -573,46 +470,31 @@ describe('DocumentProcessing', () => { />, ) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Loading State Tests - // ------------------------------------------------------------------------- describe('Loading State', () => { it('should pass isFetchingParams to Actions component', () => { - // Arrange setupMocks({ isFetchingParams: true }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert - check that the process button is disabled when fetching const processButton = screen.getByText('datasetPipeline.operations.process') expect(processButton.closest('button')).toBeDisabled() }) it('should enable process button when not fetching', () => { - // Arrange setupMocks({ isFetchingParams: false }) - // Act renderWithQueryClient(<DocumentProcessing {...defaultProps} />) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process') expect(processButton.closest('button')).not.toBeDisabled() }) }) }) -// ============================================================================ -// Actions Component Tests -// ============================================================================ - -// Helper to create mock form params for Actions tests const createMockFormParams = (overrides?: Partial<{ handleSubmit: ReturnType<typeof vi.fn> isSubmitting: boolean @@ -631,10 +513,8 @@ describe('Actions', () => { describe('Rendering', () => { it('should render back button', () => { - // Arrange const mockFormParams = createMockFormParams() - // Act render( <Actions formParams={mockFormParams} @@ -642,15 +522,12 @@ describe('Actions', () => { />, ) - // Assert expect(screen.getByText('datasetPipeline.operations.backToDataSource')).toBeInTheDocument() }) it('should render process button', () => { - // Arrange const mockFormParams = createMockFormParams() - // Act render( <Actions formParams={mockFormParams} @@ -658,17 +535,14 @@ describe('Actions', () => { />, ) - // Assert expect(screen.getByText('datasetPipeline.operations.process')).toBeInTheDocument() }) }) describe('Button States', () => { it('should disable process button when runDisabled is true', () => { - // Arrange const mockFormParams = createMockFormParams() - // Act render( <Actions formParams={mockFormParams} @@ -677,16 +551,13 @@ describe('Actions', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() }) it('should disable process button when isSubmitting is true', () => { - // Arrange const mockFormParams = createMockFormParams({ isSubmitting: true }) - // Act render( <Actions formParams={mockFormParams} @@ -694,16 +565,13 @@ describe('Actions', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() }) it('should disable process button when canSubmit is false', () => { - // Arrange const mockFormParams = createMockFormParams({ canSubmit: false }) - // Act render( <Actions formParams={mockFormParams} @@ -711,19 +579,16 @@ describe('Actions', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() }) it('should disable process button when workflow is running', () => { - // Arrange mockWorkflowRunningData = { result: { status: WorkflowRunningStatus.Running }, } const mockFormParams = createMockFormParams() - // Act render( <Actions formParams={mockFormParams} @@ -731,19 +596,16 @@ describe('Actions', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() }) it('should enable process button when all conditions are met', () => { - // Arrange mockWorkflowRunningData = { result: { status: WorkflowRunningStatus.Succeeded }, } const mockFormParams = createMockFormParams() - // Act render( <Actions formParams={mockFormParams} @@ -752,7 +614,6 @@ describe('Actions', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).not.toBeDisabled() }) @@ -760,11 +621,9 @@ describe('Actions', () => { describe('User Interactions', () => { it('should call onBack when back button is clicked', () => { - // Arrange const mockOnBack = vi.fn() const mockFormParams = createMockFormParams() - // Act render( <Actions formParams={mockFormParams} @@ -774,16 +633,13 @@ describe('Actions', () => { fireEvent.click(screen.getByText('datasetPipeline.operations.backToDataSource')) - // Assert expect(mockOnBack).toHaveBeenCalledTimes(1) }) it('should call form.handleSubmit when process button is clicked', () => { - // Arrange const mockSubmit = vi.fn() const mockFormParams = createMockFormParams({ handleSubmit: mockSubmit }) - // Act render( <Actions formParams={mockFormParams} @@ -793,17 +649,14 @@ describe('Actions', () => { fireEvent.click(screen.getByText('datasetPipeline.operations.process')) - // Assert expect(mockSubmit).toHaveBeenCalledTimes(1) }) }) describe('Loading State', () => { it('should show loading state when isSubmitting', () => { - // Arrange const mockFormParams = createMockFormParams({ isSubmitting: true }) - // Act render( <Actions formParams={mockFormParams} @@ -811,19 +664,16 @@ describe('Actions', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() }) it('should show loading state when workflow is running', () => { - // Arrange mockWorkflowRunningData = { result: { status: WorkflowRunningStatus.Running }, } const mockFormParams = createMockFormParams() - // Act render( <Actions formParams={mockFormParams} @@ -831,7 +681,6 @@ describe('Actions', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() }) @@ -839,10 +688,8 @@ describe('Actions', () => { describe('Edge Cases', () => { it('should handle undefined runDisabled prop', () => { - // Arrange const mockFormParams = createMockFormParams() - // Act render( <Actions formParams={mockFormParams} @@ -850,17 +697,14 @@ describe('Actions', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).not.toBeDisabled() }) it('should handle undefined workflowRunningData', () => { - // Arrange mockWorkflowRunningData = undefined const mockFormParams = createMockFormParams() - // Act render( <Actions formParams={mockFormParams} @@ -868,7 +712,6 @@ describe('Actions', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).not.toBeDisabled() }) @@ -876,7 +719,6 @@ describe('Actions', () => { describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange const mockFormParams = createMockFormParams() const mockOnBack = vi.fn() const { rerender } = render( @@ -886,7 +728,6 @@ describe('Actions', () => { />, ) - // Act - rerender with same props rerender( <Actions formParams={mockFormParams} @@ -894,16 +735,11 @@ describe('Actions', () => { />, ) - // Assert expect(screen.getByText('datasetPipeline.operations.backToDataSource')).toBeInTheDocument() }) }) }) -// ============================================================================ -// Options Component Tests -// ============================================================================ - describe('Options', () => { beforeEach(() => { vi.clearAllMocks() @@ -912,7 +748,6 @@ describe('Options', () => { describe('Rendering', () => { it('should render form element', () => { - // Arrange const props = { initialData: {}, configurations: [], @@ -921,15 +756,12 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act const { container } = render(<Options {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) it('should render fields based on configurations', () => { - // Arrange const configurations = [ createBaseConfiguration({ variable: 'name', label: 'Name' }), createBaseConfiguration({ variable: 'email', label: 'Email' }), @@ -942,16 +774,13 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act render(<Options {...props} />) - // Assert expect(screen.getByTestId('field-name')).toBeInTheDocument() expect(screen.getByTestId('field-email')).toBeInTheDocument() }) it('should render CustomActions', () => { - // Arrange const props = { initialData: {}, configurations: [], @@ -962,15 +791,12 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act render(<Options {...props} />) - // Assert expect(screen.getByTestId('custom-action')).toBeInTheDocument() }) it('should render with correct class name', () => { - // Arrange const props = { initialData: {}, configurations: [], @@ -979,10 +805,8 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act const { container } = render(<Options {...props} />) - // Assert const form = container.querySelector('form') expect(form).toHaveClass('w-full') }) @@ -990,7 +814,6 @@ describe('Options', () => { describe('Form Submission', () => { it('should prevent default form submission', () => { - // Arrange const mockOnSubmit = vi.fn() const props = { initialData: {}, @@ -1000,7 +823,6 @@ describe('Options', () => { onSubmit: mockOnSubmit, } - // Act const { container } = render(<Options {...props} />) const form = container.querySelector('form')! const submitEvent = new Event('submit', { bubbles: true, cancelable: true }) @@ -1008,12 +830,10 @@ describe('Options', () => { fireEvent(form, submitEvent) - // Assert expect(preventDefaultSpy).toHaveBeenCalled() }) it('should stop propagation on form submit', () => { - // Arrange const mockOnSubmit = vi.fn() const props = { initialData: {}, @@ -1023,7 +843,6 @@ describe('Options', () => { onSubmit: mockOnSubmit, } - // Act const { container } = render(<Options {...props} />) const form = container.querySelector('form')! const submitEvent = new Event('submit', { bubbles: true, cancelable: true }) @@ -1031,12 +850,10 @@ describe('Options', () => { fireEvent(form, submitEvent) - // Assert expect(stopPropagationSpy).toHaveBeenCalled() }) it('should call onSubmit when validation passes', () => { - // Arrange const mockOnSubmit = vi.fn() const props = { initialData: {}, @@ -1046,17 +863,14 @@ describe('Options', () => { onSubmit: mockOnSubmit, } - // Act const { container } = render(<Options {...props} />) const form = container.querySelector('form')! fireEvent.submit(form) - // Assert expect(mockOnSubmit).toHaveBeenCalled() }) it('should not call onSubmit when validation fails', () => { - // Arrange const mockOnSubmit = vi.fn() const failingSchema = { safeParse: vi.fn().mockReturnValue({ @@ -1076,17 +890,14 @@ describe('Options', () => { onSubmit: mockOnSubmit, } - // Act const { container } = render(<Options {...props} />) const form = container.querySelector('form')! fireEvent.submit(form) - // Assert expect(mockOnSubmit).not.toHaveBeenCalled() }) it('should show toast error when validation fails', () => { - // Arrange const failingSchema = { safeParse: vi.fn().mockReturnValue({ success: false, @@ -1105,12 +916,10 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act const { container } = render(<Options {...props} />) const form = container.querySelector('form')! fireEvent.submit(form) - // Assert expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Path: name Error: Name is required', @@ -1118,7 +927,6 @@ describe('Options', () => { }) it('should format error message with multiple path segments', () => { - // Arrange const failingSchema = { safeParse: vi.fn().mockReturnValue({ success: false, @@ -1137,12 +945,10 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act const { container } = render(<Options {...props} />) const form = container.querySelector('form')! fireEvent.submit(form) - // Assert expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Path: user.profile.email Error: Invalid email format', @@ -1150,7 +956,6 @@ describe('Options', () => { }) it('should only show first validation error when multiple errors exist', () => { - // Arrange const failingSchema = { safeParse: vi.fn().mockReturnValue({ success: false, @@ -1171,12 +976,10 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act const { container } = render(<Options {...props} />) const form = container.querySelector('form')! fireEvent.submit(form) - // Assert - should only show first error expect(mockToastNotify).toHaveBeenCalledTimes(1) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -1185,7 +988,6 @@ describe('Options', () => { }) it('should handle empty path in validation error', () => { - // Arrange const failingSchema = { safeParse: vi.fn().mockReturnValue({ success: false, @@ -1204,12 +1006,10 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act const { container } = render(<Options {...props} />) const form = container.querySelector('form')! fireEvent.submit(form) - // Assert expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Path: Error: Form validation failed', @@ -1219,7 +1019,6 @@ describe('Options', () => { describe('Field Rendering', () => { it('should render fields in correct order', () => { - // Arrange const configurations = [ createBaseConfiguration({ variable: 'first', label: 'First' }), createBaseConfiguration({ variable: 'second', label: 'Second' }), @@ -1233,22 +1032,18 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act render(<Options {...props} />) - // Assert - check that each field container exists with correct order expect(screen.getByTestId('field-first')).toBeInTheDocument() expect(screen.getByTestId('field-second')).toBeInTheDocument() expect(screen.getByTestId('field-third')).toBeInTheDocument() - // Verify order by checking labels within each field expect(screen.getByTestId('field-label-first')).toHaveTextContent('First') expect(screen.getByTestId('field-label-second')).toHaveTextContent('Second') expect(screen.getByTestId('field-label-third')).toHaveTextContent('Third') }) it('should pass config to BaseField', () => { - // Arrange const configurations = [ createBaseConfiguration({ variable: 'test', @@ -1265,10 +1060,8 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act render(<Options {...props} />) - // Assert expect(screen.getByTestId('field-label-test')).toHaveTextContent('Test Label') expect(screen.getByTestId('field-type-test')).toHaveTextContent(BaseFieldType.textInput) expect(screen.getByTestId('field-required-test')).toHaveTextContent('true') @@ -1277,7 +1070,6 @@ describe('Options', () => { describe('Edge Cases', () => { it('should handle empty initialData', () => { - // Arrange const props = { initialData: {}, configurations: [createBaseConfiguration()], @@ -1286,15 +1078,12 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act const { container } = render(<Options {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) it('should handle empty configurations', () => { - // Arrange const props = { initialData: {}, configurations: [], @@ -1303,15 +1092,12 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act render(<Options {...props} />) - // Assert expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument() }) it('should handle configurations with all field types', () => { - // Arrange const configurations = [ createBaseConfiguration({ type: BaseFieldType.textInput, variable: 'text' }), createBaseConfiguration({ type: BaseFieldType.paragraph, variable: 'paragraph' }), @@ -1333,10 +1119,8 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act render(<Options {...props} />) - // Assert expect(screen.getByTestId('field-text')).toBeInTheDocument() expect(screen.getByTestId('field-paragraph')).toBeInTheDocument() expect(screen.getByTestId('field-number')).toBeInTheDocument() @@ -1345,7 +1129,6 @@ describe('Options', () => { }) it('should handle large number of configurations', () => { - // Arrange const configurations = Array.from({ length: 20 }, (_, i) => createBaseConfiguration({ variable: `field_${i}`, label: `Field ${i}` })) const props = { @@ -1356,21 +1139,14 @@ describe('Options', () => { onSubmit: vi.fn(), } - // Act render(<Options {...props} />) - // Assert expect(screen.getAllByTestId(/^field-field_/)).toHaveLength(20) }) }) }) -// ============================================================================ -// useInputVariables Hook Tests -// ============================================================================ - describe('useInputVariables Hook', () => { - // Import hook directly for isolated testing // Note: The hook is tested via component tests above, but we add specific hook tests here beforeEach(() => { @@ -1382,10 +1158,8 @@ describe('useInputVariables Hook', () => { describe('Return Values', () => { it('should return isFetchingParams state', () => { - // Arrange setupMocks({ isFetchingParams: true }) - // Act renderWithQueryClient( <DocumentProcessing {...{ dataSourceNodeId: 'test-node', @@ -1395,17 +1169,14 @@ describe('useInputVariables Hook', () => { />, ) - // Assert - verified by checking process button is disabled const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() }) it('should return paramsConfig when data is loaded', () => { - // Arrange const variables = [createRAGPipelineVariable()] setupMocks({ paramsConfig: { variables } }) - // Act renderWithQueryClient( <DocumentProcessing {...{ dataSourceNodeId: 'test-node', @@ -1415,18 +1186,15 @@ describe('useInputVariables Hook', () => { />, ) - // Assert expect(mockUseInitialData).toHaveBeenCalledWith(variables) }) }) describe('Query Behavior', () => { it('should use pipelineId from store', () => { - // Arrange mockPipelineId = 'custom-pipeline-id' setupMocks() - // Act renderWithQueryClient( <DocumentProcessing {...{ dataSourceNodeId: 'test-node', @@ -1436,16 +1204,13 @@ describe('useInputVariables Hook', () => { />, ) - // Assert - component renders successfully with the pipelineId expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) it('should handle null pipelineId gracefully', () => { - // Arrange mockPipelineId = null setupMocks({ pipelineId: null }) - // Act renderWithQueryClient( <DocumentProcessing {...{ dataSourceNodeId: 'test-node', @@ -1455,16 +1220,11 @@ describe('useInputVariables Hook', () => { />, ) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('DocumentProcessing Integration', () => { beforeEach(() => { vi.clearAllMocks() @@ -1473,7 +1233,6 @@ describe('DocumentProcessing Integration', () => { describe('Full Flow', () => { it('should integrate hooks, Options, and Actions correctly', () => { - // Arrange const variables = [ createRAGPipelineVariable({ variable: 'input1', label: 'Input 1' }), createRAGPipelineVariable({ variable: 'input2', label: 'Input 2' }), @@ -1488,7 +1247,6 @@ describe('DocumentProcessing Integration', () => { initialData: { input1: '', input2: '' }, }) - // Act renderWithQueryClient( <DocumentProcessing {...{ dataSourceNodeId: 'test-node', @@ -1498,7 +1256,6 @@ describe('DocumentProcessing Integration', () => { />, ) - // Assert expect(screen.getByTestId('field-input1')).toBeInTheDocument() expect(screen.getByTestId('field-input2')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.backToDataSource')).toBeInTheDocument() @@ -1506,12 +1263,10 @@ describe('DocumentProcessing Integration', () => { }) it('should pass data through the component hierarchy', () => { - // Arrange const mockOnProcess = vi.fn() const mockOnBack = vi.fn() setupMocks() - // Act renderWithQueryClient( <DocumentProcessing dataSourceNodeId="test-node" @@ -1520,22 +1275,18 @@ describe('DocumentProcessing Integration', () => { />, ) - // Click back button fireEvent.click(screen.getByText('datasetPipeline.operations.backToDataSource')) - // Assert expect(mockOnBack).toHaveBeenCalled() }) }) describe('State Synchronization', () => { it('should update when workflow running status changes', () => { - // Arrange setupMocks({ workflowRunningData: { result: { status: WorkflowRunningStatus.Running } }, }) - // Act renderWithQueryClient( <DocumentProcessing {...{ dataSourceNodeId: 'test-node', @@ -1545,16 +1296,13 @@ describe('DocumentProcessing Integration', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() }) it('should update when fetching params status changes', () => { - // Arrange setupMocks({ isFetchingParams: true }) - // Act renderWithQueryClient( <DocumentProcessing {...{ dataSourceNodeId: 'test-node', @@ -1564,17 +1312,12 @@ describe('DocumentProcessing Integration', () => { />, ) - // Assert const processButton = screen.getByText('datasetPipeline.operations.process').closest('button') expect(processButton).toBeDisabled() }) }) }) -// ============================================================================ -// Prop Variations Tests -// ============================================================================ - describe('Prop Variations', () => { beforeEach(() => { vi.clearAllMocks() @@ -1589,7 +1332,6 @@ describe('Prop Variations', () => { ['node.with.dots'], ['very-long-node-id-that-could-potentially-cause-issues-if-not-handled-properly'], ])('should handle dataSourceNodeId: %s', (nodeId) => { - // Act renderWithQueryClient( <DocumentProcessing dataSourceNodeId={nodeId} @@ -1598,18 +1340,15 @@ describe('Prop Variations', () => { />, ) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) }) describe('Callback Variations', () => { it('should work with synchronous onProcess', () => { - // Arrange const syncCallback = vi.fn() setupMocks() - // Act renderWithQueryClient( <DocumentProcessing dataSourceNodeId="test-node" @@ -1618,16 +1357,13 @@ describe('Prop Variations', () => { />, ) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) it('should work with async onProcess', () => { - // Arrange const asyncCallback = vi.fn().mockResolvedValue(undefined) setupMocks() - // Act renderWithQueryClient( <DocumentProcessing dataSourceNodeId="test-node" @@ -1636,20 +1372,17 @@ describe('Prop Variations', () => { />, ) - // Assert expect(screen.getByTestId('form-actions')).toBeInTheDocument() }) }) describe('Configuration Variations', () => { it('should handle required fields', () => { - // Arrange const configurations = [ createBaseConfiguration({ variable: 'required', required: true }), ] setupMocks({ configurations }) - // Act renderWithQueryClient( <DocumentProcessing {...{ dataSourceNodeId: 'test-node', @@ -1659,18 +1392,15 @@ describe('Prop Variations', () => { />, ) - // Assert expect(screen.getByTestId('field-required-required')).toHaveTextContent('true') }) it('should handle optional fields', () => { - // Arrange const configurations = [ createBaseConfiguration({ variable: 'optional', required: false }), ] setupMocks({ configurations }) - // Act renderWithQueryClient( <DocumentProcessing {...{ dataSourceNodeId: 'test-node', @@ -1680,12 +1410,10 @@ describe('Prop Variations', () => { />, ) - // Assert expect(screen.getByTestId('field-required-optional')).toHaveTextContent('false') }) it('should handle mixed required and optional fields', () => { - // Arrange const configurations = [ createBaseConfiguration({ variable: 'required1', required: true }), createBaseConfiguration({ variable: 'optional1', required: false }), @@ -1693,7 +1421,6 @@ describe('Prop Variations', () => { ] setupMocks({ configurations }) - // Act renderWithQueryClient( <DocumentProcessing {...{ dataSourceNodeId: 'test-node', @@ -1703,7 +1430,6 @@ describe('Prop Variations', () => { />, ) - // Assert expect(screen.getByTestId('field-required-required1')).toHaveTextContent('true') expect(screen.getByTestId('field-required-optional1')).toHaveTextContent('false') expect(screen.getByTestId('field-required-required2')).toHaveTextContent('true') diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/__tests__/index.spec.tsx similarity index 81% rename from web/app/components/rag-pipeline/components/panel/test-run/result/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/test-run/result/__tests__/index.spec.tsx index d3204ae29a..249a13023c 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/__tests__/index.spec.tsx @@ -5,41 +5,19 @@ import * as React from 'react' import { BlockEnum, WorkflowRunningStatus } from '@/app/components/workflow/types' import { RAG_PIPELINE_PREVIEW_CHUNK_NUM } from '@/config' import { ChunkingMode } from '@/models/datasets' -import Result from './index' -import ResultPreview from './result-preview' -import { formatPreviewChunks } from './result-preview/utils' -import Tabs from './tabs' -import Tab from './tabs/tab' - -// ============================================================================ -// Pre-declare variables used in mocks (hoisting) -// ============================================================================ +import Result from '../index' +import ResultPreview from '../result-preview' +import { formatPreviewChunks } from '../result-preview/utils' +import Tabs from '../tabs' +import Tab from '../tabs/tab' let mockWorkflowRunningData: WorkflowRunningData | undefined -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string, count?: number }) => { - const ns = options?.ns ? `${options.ns}.` : '' - if (options?.count !== undefined) - return `${ns}${key} (count: ${options.count})` - return `${ns}${key}` - }, - }), -})) - -// Mock workflow store vi.mock('@/app/components/workflow/store', () => ({ useStore: <T,>(selector: (state: { workflowRunningData: WorkflowRunningData | undefined }) => T) => selector({ workflowRunningData: mockWorkflowRunningData }), })) -// Mock child components vi.mock('@/app/components/workflow/run/result-panel', () => ({ default: ({ inputs, @@ -102,10 +80,6 @@ vi.mock('@/app/components/rag-pipeline/components/chunk-card-list', () => ({ ), })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createMockWorkflowRunningData = ( overrides?: Partial<WorkflowRunningData>, ): WorkflowRunningData => ({ @@ -191,26 +165,15 @@ const createQAChunkOutputs = (qaCount: number = 5) => ({ })), }) -// ============================================================================ -// Helper Functions -// ============================================================================ - const resetAllMocks = () => { mockWorkflowRunningData = undefined } -// ============================================================================ -// Tab Component Tests -// ============================================================================ - describe('Tab', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render tab with label', () => { const mockOnClick = vi.fn() @@ -283,9 +246,6 @@ describe('Tab', () => { }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onClick with value when clicked', () => { const mockOnClick = vi.fn() @@ -325,9 +285,6 @@ describe('Tab', () => { }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should maintain stable handleClick callback reference', () => { const mockOnClick = vi.fn() @@ -353,33 +310,26 @@ describe('Tab', () => { }) }) - // ------------------------------------------------------------------------- - // Props Variation Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should render with all combinations of isActive and workflowRunningData', () => { const mockOnClick = vi.fn() const workflowData = createMockWorkflowRunningData() - // Active with data const { rerender } = render( <Tab isActive={true} label="Tab" value="tab" workflowRunningData={workflowData} onClick={mockOnClick} />, ) expect(screen.getByRole('button')).not.toBeDisabled() - // Inactive with data rerender( <Tab isActive={false} label="Tab" value="tab" workflowRunningData={workflowData} onClick={mockOnClick} />, ) expect(screen.getByRole('button')).not.toBeDisabled() - // Active without data rerender( <Tab isActive={true} label="Tab" value="tab" workflowRunningData={undefined} onClick={mockOnClick} />, ) expect(screen.getByRole('button')).toBeDisabled() - // Inactive without data rerender( <Tab isActive={false} label="Tab" value="tab" workflowRunningData={undefined} onClick={mockOnClick} />, ) @@ -388,18 +338,11 @@ describe('Tab', () => { }) }) -// ============================================================================ -// Tabs Component Tests -// ============================================================================ - describe('Tabs', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render all three tabs', () => { render( @@ -440,18 +383,12 @@ describe('Tabs', () => { ) const buttons = screen.getAllByRole('button') - // RESULT tab expect(buttons[0]).toHaveClass('border-transparent') - // DETAIL tab (active) expect(buttons[1]).toHaveClass('border-util-colors-blue-brand-blue-brand-600') - // TRACING tab expect(buttons[2]).toHaveClass('border-transparent') }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call switchTab when RESULT tab is clicked', () => { const mockSwitchTab = vi.fn() @@ -522,9 +459,6 @@ describe('Tabs', () => { }) }) - // ------------------------------------------------------------------------- - // Props Variation Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should handle all currentTab values', () => { const mockSwitchTab = vi.fn() @@ -554,14 +488,7 @@ describe('Tabs', () => { }) }) -// ============================================================================ -// formatPreviewChunks Utility Tests -// ============================================================================ - describe('formatPreviewChunks', () => { - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should return undefined when outputs is null', () => { expect(formatPreviewChunks(null)).toBeUndefined() @@ -581,9 +508,6 @@ describe('formatPreviewChunks', () => { }) }) - // ------------------------------------------------------------------------- - // General Chunks Tests - // ------------------------------------------------------------------------- describe('General Chunks (text mode)', () => { it('should format general chunks correctly', () => { const outputs = createGeneralChunkOutputs(3) @@ -613,9 +537,6 @@ describe('formatPreviewChunks', () => { }) }) - // ------------------------------------------------------------------------- - // Parent-Child Chunks Tests - // ------------------------------------------------------------------------- describe('Parent-Child Chunks (hierarchical mode)', () => { it('should format paragraph mode chunks correctly', () => { const outputs = createParentChildChunkOutputs('paragraph', 3) @@ -678,9 +599,6 @@ describe('formatPreviewChunks', () => { }) }) - // ------------------------------------------------------------------------- - // QA Chunks Tests - // ------------------------------------------------------------------------- describe('QA Chunks (qa mode)', () => { it('should format QA chunks correctly', () => { const outputs = createQAChunkOutputs(3) @@ -710,18 +628,11 @@ describe('formatPreviewChunks', () => { }) }) -// ============================================================================ -// ResultPreview Component Tests -// ============================================================================ - describe('ResultPreview', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render loading state when isRunning is true and no outputs', () => { render( @@ -778,7 +689,7 @@ describe('ResultPreview', () => { ) expect( - screen.getByText(`pipeline.result.resultPreview.footerTip (count: ${RAG_PIPELINE_PREVIEW_CHUNK_NUM})`), + screen.getByText(`pipeline.result.resultPreview.footerTip:{"count":${RAG_PIPELINE_PREVIEW_CHUNK_NUM}}`), ).toBeInTheDocument() }) @@ -799,9 +710,6 @@ describe('ResultPreview', () => { }) }) - // ------------------------------------------------------------------------- - // User Interaction Tests - // ------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onSwitchToDetail when view details button is clicked', () => { const mockOnSwitchToDetail = vi.fn() @@ -821,9 +729,6 @@ describe('ResultPreview', () => { }) }) - // ------------------------------------------------------------------------- - // Props Variation Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { it('should render with general chunks output', () => { const outputs = createGeneralChunkOutputs(3) @@ -874,9 +779,6 @@ describe('ResultPreview', () => { }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle outputs with no previewChunks result', () => { const outputs = { @@ -893,7 +795,6 @@ describe('ResultPreview', () => { />, ) - // Should not render chunk card list when formatPreviewChunks returns undefined expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument() }) @@ -907,14 +808,10 @@ describe('ResultPreview', () => { />, ) - // Error section should not render when isRunning is true expect(screen.queryByText('pipeline.result.resultPreview.error')).not.toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should memoize previewChunks calculation', () => { const outputs = createGeneralChunkOutputs(3) @@ -927,7 +824,6 @@ describe('ResultPreview', () => { />, ) - // Re-render with same outputs - should use memoized value rerender( <ResultPreview isRunning={false} @@ -942,19 +838,12 @@ describe('ResultPreview', () => { }) }) -// ============================================================================ -// Result Component Tests (Main Component) -// ============================================================================ - describe('Result', () => { beforeEach(() => { vi.clearAllMocks() resetAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render tabs and result preview by default', () => { mockWorkflowRunningData = createMockWorkflowRunningData({ @@ -967,7 +856,6 @@ describe('Result', () => { render(<Result />) - // Tabs should be rendered expect(screen.getByText('runLog.result')).toBeInTheDocument() expect(screen.getByText('runLog.detail')).toBeInTheDocument() expect(screen.getByText('runLog.tracing')).toBeInTheDocument() @@ -1003,9 +891,6 @@ describe('Result', () => { }) }) - // ------------------------------------------------------------------------- - // Tab Switching Tests - // ------------------------------------------------------------------------- describe('Tab Switching', () => { it('should switch to DETAIL tab when clicked', async () => { mockWorkflowRunningData = createMockWorkflowRunningData() @@ -1042,13 +927,11 @@ describe('Result', () => { render(<Result />) - // Switch to DETAIL fireEvent.click(screen.getByText('runLog.detail')) await waitFor(() => { expect(screen.getByTestId('result-panel')).toBeInTheDocument() }) - // Switch back to RESULT fireEvent.click(screen.getByText('runLog.result')) await waitFor(() => { expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument() @@ -1056,9 +939,6 @@ describe('Result', () => { }) }) - // ------------------------------------------------------------------------- - // DETAIL Tab Content Tests - // ------------------------------------------------------------------------- describe('DETAIL Tab Content', () => { it('should render ResultPanel with correct props', async () => { mockWorkflowRunningData = createMockWorkflowRunningData({ @@ -1109,9 +989,6 @@ describe('Result', () => { }) }) - // ------------------------------------------------------------------------- - // TRACING Tab Content Tests - // ------------------------------------------------------------------------- describe('TRACING Tab Content', () => { it('should render TracingPanel with tracing data', async () => { mockWorkflowRunningData = createMockWorkflowRunningData() @@ -1137,15 +1014,11 @@ describe('Result', () => { fireEvent.click(screen.getByText('runLog.tracing')) await waitFor(() => { - // Both TracingPanel and Loading should be rendered expect(screen.getByTestId('tracing-panel')).toBeInTheDocument() }) }) }) - // ------------------------------------------------------------------------- - // Switch to Detail from Result Preview Tests - // ------------------------------------------------------------------------- describe('Switch to Detail from Result Preview', () => { it('should switch to DETAIL tab when onSwitchToDetail is triggered from ResultPreview', async () => { mockWorkflowRunningData = createMockWorkflowRunningData({ @@ -1159,7 +1032,6 @@ describe('Result', () => { render(<Result />) - // Click the view details button in error state fireEvent.click(screen.getByText('pipeline.result.resultPreview.viewDetails')) await waitFor(() => { @@ -1168,16 +1040,12 @@ describe('Result', () => { }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle undefined workflowRunningData', () => { mockWorkflowRunningData = undefined render(<Result />) - // All tabs should be disabled const buttons = screen.getAllByRole('button') buttons.forEach((button) => { expect(button).toBeDisabled() @@ -1193,7 +1061,6 @@ describe('Result', () => { render(<Result />) - // Should show loading in RESULT tab (isRunning condition) expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument() }) @@ -1223,36 +1090,28 @@ describe('Result', () => { render(<Result />) - // Should show error when stopped expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // State Management Tests - // ------------------------------------------------------------------------- describe('State Management', () => { it('should maintain tab state across re-renders', async () => { mockWorkflowRunningData = createMockWorkflowRunningData() const { rerender } = render(<Result />) - // Switch to DETAIL tab fireEvent.click(screen.getByText('runLog.detail')) await waitFor(() => { expect(screen.getByTestId('result-panel')).toBeInTheDocument() }) - // Re-render component rerender(<Result />) - // Should still be on DETAIL tab expect(screen.getByTestId('result-panel')).toBeInTheDocument() }) it('should render different states based on workflowRunningData', () => { - // Test 1: Running state with no outputs mockWorkflowRunningData = createMockWorkflowRunningData({ result: { ...createMockWorkflowRunningData().result, @@ -1265,7 +1124,6 @@ describe('Result', () => { expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument() unmount() - // Test 2: Completed state with outputs const outputs = createGeneralChunkOutputs(3) mockWorkflowRunningData = createMockWorkflowRunningData({ result: { @@ -1280,19 +1138,14 @@ describe('Result', () => { }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized', () => { mockWorkflowRunningData = createMockWorkflowRunningData() const { rerender } = render(<Result />) - // Re-render without changes rerender(<Result />) - // Component should still be rendered correctly expect(screen.getByText('runLog.result')).toBeInTheDocument() }) }) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/index.spec.tsx similarity index 79% rename from web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/index.spec.tsx index 8dd0bf759f..1245d2aca5 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/index.spec.tsx @@ -1,32 +1,15 @@ -import type { ChunkInfo, GeneralChunks, ParentChildChunks, QAChunks } from '../../../../chunk-card-list/types' +import type { ChunkInfo, GeneralChunks, ParentChildChunks, QAChunks } from '../../../../../chunk-card-list/types' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { ChunkingMode } from '@/models/datasets' -import ResultPreview from './index' -import { formatPreviewChunks } from './utils' +import ResultPreview from '../index' +import { formatPreviewChunks } from '../utils' -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string, count?: number }) => { - const ns = options?.ns ? `${options.ns}.` : '' - const count = options?.count !== undefined ? ` (count: ${options.count})` : '' - return `${ns}${key}${count}` - }, - }), -})) - -// Mock config vi.mock('@/config', () => ({ RAG_PIPELINE_PREVIEW_CHUNK_NUM: 20, })) -// Mock ChunkCardList component -vi.mock('../../../../chunk-card-list', () => ({ +vi.mock('../../../../../chunk-card-list', () => ({ ChunkCardList: ({ chunkType, chunkInfo }: { chunkType: string, chunkInfo: ChunkInfo }) => ( <div data-testid="chunk-card-list" data-chunk-type={chunkType} data-chunk-info={JSON.stringify(chunkInfo)}> ChunkCardList @@ -34,10 +17,6 @@ vi.mock('../../../../chunk-card-list', () => ({ ), })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - /** * Factory for creating general chunk preview outputs */ @@ -98,52 +77,35 @@ const createMockQAChunks = (count: number): Array<{ question: string, answer: st })) } -// ============================================================================ -// formatPreviewChunks Utility Tests -// ============================================================================ - describe('formatPreviewChunks', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Null/Undefined Input Tests - // ------------------------------------------------------------------------- describe('Null/Undefined Input', () => { it('should return undefined when outputs is undefined', () => { - // Arrange & Act const result = formatPreviewChunks(undefined) - // Assert expect(result).toBeUndefined() }) it('should return undefined when outputs is null', () => { - // Arrange & Act const result = formatPreviewChunks(null) - // Assert expect(result).toBeUndefined() }) }) - // ------------------------------------------------------------------------- - // General Chunks (text_model) Tests - // ------------------------------------------------------------------------- describe('General Chunks (text_model)', () => { it('should format general chunks correctly', () => { - // Arrange const outputs = createGeneralChunkOutputs([ { content: 'First chunk content' }, { content: 'Second chunk content' }, { content: 'Third chunk content' }, ]) - // Act const result = formatPreviewChunks(outputs) as GeneralChunks - // Assert expect(result).toEqual([ { content: 'First chunk content', summary: undefined }, { content: 'Second chunk content', summary: undefined }, @@ -152,40 +114,31 @@ describe('formatPreviewChunks', () => { }) it('should limit general chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM (20)', () => { - // Arrange const outputs = createGeneralChunkOutputs(createMockGeneralChunks(30)) - // Act const result = formatPreviewChunks(outputs) as GeneralChunks - // Assert expect(result).toHaveLength(20) expect((result as GeneralChunks)[0].content).toBe('Chunk content 1') expect((result as GeneralChunks)[19].content).toBe('Chunk content 20') }) it('should handle empty preview array for general chunks', () => { - // Arrange const outputs = createGeneralChunkOutputs([]) - // Act const result = formatPreviewChunks(outputs) as GeneralChunks - // Assert expect(result).toEqual([]) }) it('should handle general chunks with empty content', () => { - // Arrange const outputs = createGeneralChunkOutputs([ { content: '' }, { content: 'Valid content' }, ]) - // Act const result = formatPreviewChunks(outputs) as GeneralChunks - // Assert expect(result).toEqual([ { content: '', summary: undefined }, { content: 'Valid content', summary: undefined }, @@ -193,17 +146,14 @@ describe('formatPreviewChunks', () => { }) it('should handle general chunks with special characters', () => { - // Arrange const outputs = createGeneralChunkOutputs([ { content: '<script>alert("xss")</script>' }, { content: 'äž­æ–‡ć†…ćźč 🎉' }, { content: 'Line1\nLine2\tTab' }, ]) - // Act const result = formatPreviewChunks(outputs) as GeneralChunks - // Assert expect(result).toEqual([ { content: '<script>alert("xss")</script>', summary: undefined }, { content: 'äž­æ–‡ć†…ćźč 🎉', summary: undefined }, @@ -212,34 +162,25 @@ describe('formatPreviewChunks', () => { }) it('should handle general chunks with very long content', () => { - // Arrange const longContent = 'A'.repeat(10000) const outputs = createGeneralChunkOutputs([{ content: longContent }]) - // Act const result = formatPreviewChunks(outputs) as GeneralChunks - // Assert expect((result as GeneralChunks)[0].content).toHaveLength(10000) }) }) - // ------------------------------------------------------------------------- - // Parent-Child Chunks (hierarchical_model) Tests - // ------------------------------------------------------------------------- describe('Parent-Child Chunks (hierarchical_model)', () => { describe('Paragraph Mode', () => { it('should format parent-child chunks in paragraph mode correctly', () => { - // Arrange const outputs = createParentChildChunkOutputs([ { content: 'Parent 1', child_chunks: ['Child 1-1', 'Child 1-2'] }, { content: 'Parent 2', child_chunks: ['Child 2-1'] }, ], 'paragraph') - // Act const result = formatPreviewChunks(outputs) as ParentChildChunks - // Assert expect(result.parent_mode).toBe('paragraph') expect(result.parent_child_chunks).toHaveLength(2) expect(result.parent_child_chunks[0]).toEqual({ @@ -255,54 +196,42 @@ describe('formatPreviewChunks', () => { }) it('should limit parent chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM (20) in paragraph mode', () => { - // Arrange const outputs = createParentChildChunkOutputs(createMockParentChildChunks(30, 2), 'paragraph') - // Act const result = formatPreviewChunks(outputs) as ParentChildChunks - // Assert expect(result.parent_child_chunks).toHaveLength(20) }) it('should NOT limit child chunks in paragraph mode', () => { - // Arrange const outputs = createParentChildChunkOutputs([ { content: 'Parent 1', child_chunks: Array.from({ length: 50 }, (_, i) => `Child ${i + 1}`) }, ], 'paragraph') - // Act const result = formatPreviewChunks(outputs) as ParentChildChunks - // Assert expect(result.parent_child_chunks[0].child_contents).toHaveLength(50) }) it('should handle empty child_chunks in paragraph mode', () => { - // Arrange const outputs = createParentChildChunkOutputs([ { content: 'Parent with no children', child_chunks: [] }, ], 'paragraph') - // Act const result = formatPreviewChunks(outputs) as ParentChildChunks - // Assert expect(result.parent_child_chunks[0].child_contents).toEqual([]) }) }) describe('Full-Doc Mode', () => { it('should format parent-child chunks in full-doc mode correctly', () => { - // Arrange const outputs = createParentChildChunkOutputs([ { content: 'Full Doc Parent', child_chunks: ['Child 1', 'Child 2', 'Child 3'] }, ], 'full-doc') - // Act const result = formatPreviewChunks(outputs) as ParentChildChunks - // Assert expect(result.parent_mode).toBe('full-doc') expect(result.parent_child_chunks).toHaveLength(1) expect(result.parent_child_chunks[0].parent_content).toBe('Full Doc Parent') @@ -310,74 +239,56 @@ describe('formatPreviewChunks', () => { }) it('should NOT limit parent chunks in full-doc mode', () => { - // Arrange const outputs = createParentChildChunkOutputs(createMockParentChildChunks(30, 2), 'full-doc') - // Act const result = formatPreviewChunks(outputs) as ParentChildChunks - // Assert - full-doc mode processes all parents (forEach without slice) expect(result.parent_child_chunks).toHaveLength(30) }) it('should limit child chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM (20) in full-doc mode', () => { - // Arrange const outputs = createParentChildChunkOutputs([ { content: 'Parent', child_chunks: Array.from({ length: 50 }, (_, i) => `Child ${i + 1}`) }, ], 'full-doc') - // Act const result = formatPreviewChunks(outputs) as ParentChildChunks - // Assert expect(result.parent_child_chunks[0].child_contents).toHaveLength(20) expect(result.parent_child_chunks[0].child_contents[0]).toBe('Child 1') expect(result.parent_child_chunks[0].child_contents[19]).toBe('Child 20') }) it('should handle multiple parents with many children in full-doc mode', () => { - // Arrange const outputs = createParentChildChunkOutputs([ { content: 'Parent 1', child_chunks: Array.from({ length: 25 }, (_, i) => `P1-Child ${i + 1}`) }, { content: 'Parent 2', child_chunks: Array.from({ length: 30 }, (_, i) => `P2-Child ${i + 1}`) }, ], 'full-doc') - // Act const result = formatPreviewChunks(outputs) as ParentChildChunks - // Assert expect(result.parent_child_chunks[0].child_contents).toHaveLength(20) expect(result.parent_child_chunks[1].child_contents).toHaveLength(20) }) }) it('should handle empty preview array for parent-child chunks', () => { - // Arrange const outputs = createParentChildChunkOutputs([], 'paragraph') - // Act const result = formatPreviewChunks(outputs) as ParentChildChunks - // Assert expect(result.parent_child_chunks).toEqual([]) }) }) - // ------------------------------------------------------------------------- - // QA Chunks (qa_model) Tests - // ------------------------------------------------------------------------- describe('QA Chunks (qa_model)', () => { it('should format QA chunks correctly', () => { - // Arrange const outputs = createQAChunkOutputs([ { question: 'What is Dify?', answer: 'Dify is an LLM application platform.' }, { question: 'How to use it?', answer: 'You can create apps easily.' }, ]) - // Act const result = formatPreviewChunks(outputs) as QAChunks - // Assert expect(result.qa_chunks).toHaveLength(2) expect(result.qa_chunks[0]).toEqual({ question: 'What is Dify?', @@ -390,38 +301,29 @@ describe('formatPreviewChunks', () => { }) it('should limit QA chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM (20)', () => { - // Arrange const outputs = createQAChunkOutputs(createMockQAChunks(30)) - // Act const result = formatPreviewChunks(outputs) as QAChunks - // Assert expect(result.qa_chunks).toHaveLength(20) }) it('should handle empty qa_preview array', () => { - // Arrange const outputs = createQAChunkOutputs([]) - // Act const result = formatPreviewChunks(outputs) as QAChunks - // Assert expect(result.qa_chunks).toEqual([]) }) it('should handle QA chunks with empty question or answer', () => { - // Arrange const outputs = createQAChunkOutputs([ { question: '', answer: 'Answer without question' }, { question: 'Question without answer', answer: '' }, ]) - // Act const result = formatPreviewChunks(outputs) as QAChunks - // Assert expect(result.qa_chunks[0].question).toBe('') expect(result.qa_chunks[0].answer).toBe('Answer without question') expect(result.qa_chunks[1].question).toBe('Question without answer') @@ -429,7 +331,6 @@ describe('formatPreviewChunks', () => { }) it('should preserve all properties when spreading chunk', () => { - // Arrange const outputs = { chunk_structure: ChunkingMode.qa, qa_preview: [ @@ -437,90 +338,63 @@ describe('formatPreviewChunks', () => { ] as unknown as Array<{ question: string, answer: string }>, } - // Act const result = formatPreviewChunks(outputs) as QAChunks - // Assert expect(result.qa_chunks[0]).toEqual({ question: 'Q1', answer: 'A1', extra: 'should be preserved' }) }) }) - // ------------------------------------------------------------------------- - // Unknown Chunking Mode Tests - // ------------------------------------------------------------------------- describe('Unknown Chunking Mode', () => { it('should return undefined for unknown chunking mode', () => { - // Arrange const outputs = { chunk_structure: 'unknown_mode' as ChunkingMode, preview: [], } - // Act const result = formatPreviewChunks(outputs) - // Assert expect(result).toBeUndefined() }) it('should return undefined when chunk_structure is missing', () => { - // Arrange const outputs = { preview: [{ content: 'test' }], } - // Act const result = formatPreviewChunks(outputs) - // Assert expect(result).toBeUndefined() }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle exactly RAG_PIPELINE_PREVIEW_CHUNK_NUM (20) chunks', () => { - // Arrange const outputs = createGeneralChunkOutputs(createMockGeneralChunks(20)) - // Act const result = formatPreviewChunks(outputs) as GeneralChunks - // Assert expect(result).toHaveLength(20) }) it('should handle outputs with additional properties', () => { - // Arrange const outputs = { ...createGeneralChunkOutputs([{ content: 'Test' }]), extra_field: 'should not affect result', metadata: { some: 'data' }, } - // Act const result = formatPreviewChunks(outputs) as GeneralChunks - // Assert expect(result).toEqual([{ content: 'Test', summary: undefined }]) }) }) }) -// ============================================================================ -// ResultPreview Component Tests -// ============================================================================ - describe('ResultPreview', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Default Props Factory - // ------------------------------------------------------------------------- const defaultProps = { isRunning: false, outputs: undefined, @@ -528,117 +402,85 @@ describe('ResultPreview', () => { onSwitchToDetail: vi.fn(), } - // ------------------------------------------------------------------------- - // Rendering Tests - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing with minimal props', () => { - // Arrange & Act render(<ResultPreview onSwitchToDetail={vi.fn()} />) - // Assert - Component renders (no visible content in empty state) expect(document.body).toBeInTheDocument() }) it('should render loading state when isRunning and no outputs', () => { - // Arrange & Act render(<ResultPreview {...defaultProps} isRunning={true} outputs={undefined} />) - // Assert expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument() }) it('should render loading spinner icon when loading', () => { - // Arrange & Act const { container } = render(<ResultPreview {...defaultProps} isRunning={true} outputs={undefined} />) - // Assert - Check for animate-spin class (loading spinner) const spinner = container.querySelector('.animate-spin') expect(spinner).toBeInTheDocument() }) it('should render error state when not running and error exists', () => { - // Arrange & Act render(<ResultPreview {...defaultProps} isRunning={false} error="Something went wrong" />) - // Assert expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument() expect(screen.getByRole('button', { name: /pipeline\.result\.resultPreview\.viewDetails/i })).toBeInTheDocument() }) it('should render outputs when available', () => { - // Arrange const outputs = createGeneralChunkOutputs([{ content: 'Test chunk' }]) - // Act render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument() }) it('should render footer tip when outputs available', () => { - // Arrange const outputs = createGeneralChunkOutputs([{ content: 'Test chunk' }]) - // Act render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert expect(screen.getByText(/pipeline\.result\.resultPreview\.footerTip/)).toBeInTheDocument() }) it('should not render loading when outputs exist even if isRunning', () => { - // Arrange const outputs = createGeneralChunkOutputs([{ content: 'Test' }]) - // Act render(<ResultPreview {...defaultProps} isRunning={true} outputs={outputs} />) - // Assert - Should show outputs, not loading expect(screen.queryByText('pipeline.result.resultPreview.loading')).not.toBeInTheDocument() expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument() }) it('should not render error when isRunning is true', () => { - // Arrange & Act render(<ResultPreview {...defaultProps} isRunning={true} error="Error message" outputs={undefined} />) - // Assert - Should show loading, not error expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument() expect(screen.queryByText('pipeline.result.resultPreview.error')).not.toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Props Variations Tests - // ------------------------------------------------------------------------- describe('Props Variations', () => { describe('isRunning prop', () => { it('should show loading when isRunning=true and no outputs', () => { - // Arrange & Act render(<ResultPreview {...defaultProps} isRunning={true} />) - // Assert expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument() }) it('should not show loading when isRunning=false', () => { - // Arrange & Act render(<ResultPreview {...defaultProps} isRunning={false} />) - // Assert expect(screen.queryByText('pipeline.result.resultPreview.loading')).not.toBeInTheDocument() }) it('should prioritize outputs over loading state', () => { - // Arrange const outputs = createGeneralChunkOutputs([{ content: 'Data' }]) - // Act render(<ResultPreview {...defaultProps} isRunning={true} outputs={outputs} />) - // Assert expect(screen.queryByText('pipeline.result.resultPreview.loading')).not.toBeInTheDocument() expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument() }) @@ -646,28 +488,22 @@ describe('ResultPreview', () => { describe('outputs prop', () => { it('should pass chunk_structure to ChunkCardList', () => { - // Arrange const outputs = createGeneralChunkOutputs([{ content: 'Test' }]) - // Act render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert const chunkList = screen.getByTestId('chunk-card-list') expect(chunkList).toHaveAttribute('data-chunk-type', ChunkingMode.text) }) it('should format and pass previewChunks to ChunkCardList', () => { - // Arrange const outputs = createGeneralChunkOutputs([ { content: 'Chunk 1' }, { content: 'Chunk 2' }, ]) - // Act render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert const chunkList = screen.getByTestId('chunk-card-list') const chunkInfo = JSON.parse(chunkList.getAttribute('data-chunk-info') || '[]') expect(chunkInfo).toEqual([ @@ -677,29 +513,23 @@ describe('ResultPreview', () => { }) it('should handle parent-child outputs', () => { - // Arrange const outputs = createParentChildChunkOutputs([ { content: 'Parent', child_chunks: ['Child 1', 'Child 2'] }, ]) - // Act render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert const chunkList = screen.getByTestId('chunk-card-list') expect(chunkList).toHaveAttribute('data-chunk-type', ChunkingMode.parentChild) }) it('should handle QA outputs', () => { - // Arrange const outputs = createQAChunkOutputs([ { question: 'Q1', answer: 'A1' }, ]) - // Act render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert const chunkList = screen.getByTestId('chunk-card-list') expect(chunkList).toHaveAttribute('data-chunk-type', ChunkingMode.qa) }) @@ -707,29 +537,22 @@ describe('ResultPreview', () => { describe('error prop', () => { it('should show error state when error is a non-empty string', () => { - // Arrange & Act render(<ResultPreview {...defaultProps} error="Network error" />) - // Assert expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument() }) it('should show error state when error is an empty string', () => { - // Arrange & Act render(<ResultPreview {...defaultProps} error="" />) - // Assert - Empty string is falsy, so error state should NOT show expect(screen.queryByText('pipeline.result.resultPreview.error')).not.toBeInTheDocument() }) it('should render both outputs and error when both exist (independent conditions)', () => { - // Arrange const outputs = createGeneralChunkOutputs([{ content: 'Data' }]) - // Act render(<ResultPreview {...defaultProps} outputs={outputs} error="Error" />) - // Assert - Both are rendered because conditions are independent in the component expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument() expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument() }) @@ -737,65 +560,48 @@ describe('ResultPreview', () => { describe('onSwitchToDetail prop', () => { it('should be called when view details button is clicked', () => { - // Arrange const onSwitchToDetail = vi.fn() render(<ResultPreview {...defaultProps} error="Error" onSwitchToDetail={onSwitchToDetail} />) - // Act fireEvent.click(screen.getByRole('button', { name: /viewDetails/i })) - // Assert expect(onSwitchToDetail).toHaveBeenCalledTimes(1) }) it('should not be called automatically on render', () => { - // Arrange const onSwitchToDetail = vi.fn() - // Act render(<ResultPreview {...defaultProps} error="Error" onSwitchToDetail={onSwitchToDetail} />) - // Assert expect(onSwitchToDetail).not.toHaveBeenCalled() }) }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - // ------------------------------------------------------------------------- describe('Memoization', () => { describe('React.memo wrapper', () => { it('should be wrapped with React.memo', () => { - // Arrange & Act const { rerender } = render(<ResultPreview {...defaultProps} />) rerender(<ResultPreview {...defaultProps} />) - // Assert - Component renders correctly after rerender expect(document.body).toBeInTheDocument() }) it('should update when props change', () => { - // Arrange const { rerender } = render(<ResultPreview {...defaultProps} isRunning={false} />) - // Act rerender(<ResultPreview {...defaultProps} isRunning={true} />) - // Assert expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument() }) it('should update when outputs change', () => { - // Arrange const outputs1 = createGeneralChunkOutputs([{ content: 'First' }]) const { rerender } = render(<ResultPreview {...defaultProps} outputs={outputs1} />) - // Act const outputs2 = createGeneralChunkOutputs([{ content: 'Second' }]) rerender(<ResultPreview {...defaultProps} outputs={outputs2} />) - // Assert const chunkList = screen.getByTestId('chunk-card-list') const chunkInfo = JSON.parse(chunkList.getAttribute('data-chunk-info') || '[]') expect(chunkInfo).toEqual([{ content: 'Second' }]) @@ -804,23 +610,19 @@ describe('ResultPreview', () => { describe('useMemo for previewChunks', () => { it('should compute previewChunks based on outputs', () => { - // Arrange const outputs = createGeneralChunkOutputs([ { content: 'Memoized chunk 1' }, { content: 'Memoized chunk 2' }, ]) - // Act render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert const chunkList = screen.getByTestId('chunk-card-list') const chunkInfo = JSON.parse(chunkList.getAttribute('data-chunk-info') || '[]') expect(chunkInfo).toHaveLength(2) }) it('should recompute when outputs reference changes', () => { - // Arrange const outputs1 = createGeneralChunkOutputs([{ content: 'Original' }]) const { rerender } = render(<ResultPreview {...defaultProps} outputs={outputs1} />) @@ -828,64 +630,47 @@ describe('ResultPreview', () => { let chunkInfo = JSON.parse(chunkList.getAttribute('data-chunk-info') || '[]') expect(chunkInfo).toEqual([{ content: 'Original' }]) - // Act - Change outputs const outputs2 = createGeneralChunkOutputs([{ content: 'Updated' }]) rerender(<ResultPreview {...defaultProps} outputs={outputs2} />) - // Assert chunkList = screen.getByTestId('chunk-card-list') chunkInfo = JSON.parse(chunkList.getAttribute('data-chunk-info') || '[]') expect(chunkInfo).toEqual([{ content: 'Updated' }]) }) it('should handle undefined outputs in useMemo', () => { - // Arrange & Act render(<ResultPreview {...defaultProps} outputs={undefined} />) - // Assert - No chunk list rendered expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument() }) }) }) - // ------------------------------------------------------------------------- - // Event Handlers Tests - // ------------------------------------------------------------------------- describe('Event Handlers', () => { it('should call onSwitchToDetail when view details button is clicked', () => { - // Arrange const onSwitchToDetail = vi.fn() render(<ResultPreview {...defaultProps} error="Test error" onSwitchToDetail={onSwitchToDetail} />) - // Act fireEvent.click(screen.getByRole('button', { name: /viewDetails/i })) - // Assert expect(onSwitchToDetail).toHaveBeenCalledTimes(1) }) it('should handle multiple clicks on view details button', () => { - // Arrange const onSwitchToDetail = vi.fn() render(<ResultPreview {...defaultProps} error="Test error" onSwitchToDetail={onSwitchToDetail} />) const button = screen.getByRole('button', { name: /viewDetails/i }) - // Act fireEvent.click(button) fireEvent.click(button) fireEvent.click(button) - // Assert expect(onSwitchToDetail).toHaveBeenCalledTimes(3) }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty state (all props undefined/false)', () => { - // Arrange & Act const { container } = render( <ResultPreview isRunning={false} @@ -895,240 +680,178 @@ describe('ResultPreview', () => { />, ) - // Assert - Should render empty fragment expect(container.firstChild).toBeNull() }) it('should handle outputs with empty preview chunks', () => { - // Arrange const outputs = createGeneralChunkOutputs([]) - // Act render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert const chunkList = screen.getByTestId('chunk-card-list') const chunkInfo = JSON.parse(chunkList.getAttribute('data-chunk-info') || '[]') expect(chunkInfo).toEqual([]) }) it('should handle outputs that result in undefined previewChunks', () => { - // Arrange const outputs = { chunk_structure: 'invalid_mode' as ChunkingMode, preview: [], } - // Act render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert - Should not render chunk list when previewChunks is undefined expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument() }) it('should handle unmount cleanly', () => { - // Arrange const { unmount } = render(<ResultPreview {...defaultProps} />) - // Assert expect(() => unmount()).not.toThrow() }) it('should handle rapid prop changes', () => { - // Arrange const { rerender } = render(<ResultPreview {...defaultProps} />) - // Act - Rapidly change props rerender(<ResultPreview {...defaultProps} isRunning={true} />) rerender(<ResultPreview {...defaultProps} isRunning={false} error="Error" />) rerender(<ResultPreview {...defaultProps} outputs={createGeneralChunkOutputs([{ content: 'Test' }])} />) rerender(<ResultPreview {...defaultProps} />) - // Assert expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument() }) it('should handle very large number of chunks', () => { - // Arrange const outputs = createGeneralChunkOutputs(createMockGeneralChunks(1000)) - // Act render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert - Should only show first 20 chunks const chunkList = screen.getByTestId('chunk-card-list') const chunkInfo = JSON.parse(chunkList.getAttribute('data-chunk-info') || '[]') expect(chunkInfo).toHaveLength(20) }) it('should throw when outputs has null preview (slice called on null)', () => { - // Arrange const outputs = { chunk_structure: ChunkingMode.text, preview: null as unknown as Array<{ content: string }>, } - // Act & Assert - Component throws because slice is called on null preview - // This is expected behavior - the component doesn't validate input expect(() => render(<ResultPreview {...defaultProps} outputs={outputs} />)).toThrow() }) }) - // ------------------------------------------------------------------------- - // Integration Tests - // ------------------------------------------------------------------------- describe('Integration', () => { it('should transition from loading to output state', () => { - // Arrange const { rerender } = render(<ResultPreview {...defaultProps} isRunning={true} />) expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument() - // Act const outputs = createGeneralChunkOutputs([{ content: 'Loaded data' }]) rerender(<ResultPreview {...defaultProps} isRunning={false} outputs={outputs} />) - // Assert expect(screen.queryByText('pipeline.result.resultPreview.loading')).not.toBeInTheDocument() expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument() }) it('should transition from loading to error state', () => { - // Arrange const { rerender } = render(<ResultPreview {...defaultProps} isRunning={true} />) expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument() - // Act rerender(<ResultPreview {...defaultProps} isRunning={false} error="Failed to load" />) - // Assert expect(screen.queryByText('pipeline.result.resultPreview.loading')).not.toBeInTheDocument() expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument() }) it('should render both error and outputs when both props provided', () => { - // Arrange const { rerender } = render(<ResultPreview {...defaultProps} error="Initial error" />) expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument() - // Act - Outputs provided while error still exists const outputs = createGeneralChunkOutputs([{ content: 'Success data' }]) rerender(<ResultPreview {...defaultProps} error="Initial error" outputs={outputs} />) - // Assert - Both are rendered (component uses independent conditions) expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument() expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument() }) it('should hide error when error prop is cleared', () => { - // Arrange const { rerender } = render(<ResultPreview {...defaultProps} error="Initial error" />) expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument() - // Act - Clear error and provide outputs const outputs = createGeneralChunkOutputs([{ content: 'Success data' }]) rerender(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert - Only outputs shown when error is cleared expect(screen.queryByText('pipeline.result.resultPreview.error')).not.toBeInTheDocument() expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument() }) it('should handle complete flow: empty -> loading -> outputs', () => { - // Arrange const { rerender, container } = render(<ResultPreview {...defaultProps} />) expect(container.firstChild).toBeNull() - // Act - Start loading rerender(<ResultPreview {...defaultProps} isRunning={true} />) expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument() - // Act - Receive outputs const outputs = createGeneralChunkOutputs([{ content: 'Final data' }]) rerender(<ResultPreview {...defaultProps} isRunning={false} outputs={outputs} />) - // Assert expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument() }) }) - // ------------------------------------------------------------------------- - // Styling Tests - // ------------------------------------------------------------------------- describe('Styling', () => { it('should have correct container classes for loading state', () => { - // Arrange & Act const { container } = render(<ResultPreview {...defaultProps} isRunning={true} />) - // Assert const loadingContainer = container.querySelector('.flex.grow.flex-col.items-center.justify-center') expect(loadingContainer).toBeInTheDocument() }) it('should have correct container classes for error state', () => { - // Arrange & Act const { container } = render(<ResultPreview {...defaultProps} error="Error" />) - // Assert const errorContainer = container.querySelector('.flex.grow.flex-col.items-center.justify-center') expect(errorContainer).toBeInTheDocument() }) it('should have correct container classes for outputs state', () => { - // Arrange const outputs = createGeneralChunkOutputs([{ content: 'Test' }]) - // Act const { container } = render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert const outputContainer = container.querySelector('.flex.grow.flex-col.bg-background-body') expect(outputContainer).toBeInTheDocument() }) it('should have gradient dividers in footer', () => { - // Arrange const outputs = createGeneralChunkOutputs([{ content: 'Test' }]) - // Act const { container } = render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert const gradientDividers = container.querySelectorAll('.bg-gradient-to-r, .bg-gradient-to-l') expect(gradientDividers.length).toBeGreaterThanOrEqual(2) }) }) - // ------------------------------------------------------------------------- - // Accessibility Tests - // ------------------------------------------------------------------------- describe('Accessibility', () => { it('should have accessible button in error state', () => { - // Arrange & Act render(<ResultPreview {...defaultProps} error="Error" />) - // Assert const button = screen.getByRole('button') expect(button).toBeInTheDocument() }) it('should have title attribute on footer tip for long text', () => { - // Arrange const outputs = createGeneralChunkOutputs([{ content: 'Test' }]) - // Act const { container } = render(<ResultPreview {...defaultProps} outputs={outputs} />) - // Assert const footerTip = container.querySelector('[title]') expect(footerTip).toBeInTheDocument() }) }) }) -// ============================================================================ -// State Transition Matrix Tests -// ============================================================================ - describe('State Transition Matrix', () => { beforeEach(() => { vi.clearAllMocks() @@ -1147,7 +870,6 @@ describe('State Transition Matrix', () => { it.each(states)( 'should render $expected state when isRunning=$isRunning, outputs=$outputs, error=$error', ({ isRunning, outputs, error, expected }) => { - // Arrange & Act const { container } = render( <ResultPreview isRunning={isRunning} @@ -1157,7 +879,6 @@ describe('State Transition Matrix', () => { />, ) - // Assert switch (expected) { case 'empty': expect(container.firstChild).toBeNull() diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/index.spec.tsx similarity index 81% rename from web/app/components/rag-pipeline/components/panel/test-run/result/tabs/index.spec.tsx rename to web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/index.spec.tsx index ec7d404f6e..65dfd06cf6 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/index.spec.tsx @@ -1,25 +1,8 @@ import type { WorkflowRunningData } from '@/app/components/workflow/types' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import Tabs from './index' -import Tab from './tab' - -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const ns = options?.ns ? `${options.ns}.` : '' - return `${ns}${key}` - }, - }), -})) - -// ============================================================================ -// Test Data Factories -// ============================================================================ +import Tabs from '../index' +import Tab from '../tab' /** * Factory function to create mock WorkflowRunningData @@ -52,25 +35,16 @@ const createWorkflowRunningData = ( ...overrides, }) -// ============================================================================ -// Tab Component Tests -// ============================================================================ - describe('Tab', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - Verify basic component rendering - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render tab with label correctly', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -81,16 +55,13 @@ describe('Tab', () => { />, ) - // Assert expect(screen.getByRole('button', { name: 'Test Label' })).toBeInTheDocument() }) it('should render as button element with correct type', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -101,23 +72,17 @@ describe('Tab', () => { />, ) - // Assert const button = screen.getByRole('button') expect(button).toHaveAttribute('type', 'button') }) }) - // ------------------------------------------------------------------------- - // Props Tests - Verify different prop combinations - // ------------------------------------------------------------------------- describe('Props', () => { describe('isActive prop', () => { it('should apply active styles when isActive is true', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={true} @@ -128,18 +93,15 @@ describe('Tab', () => { />, ) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('border-util-colors-blue-brand-blue-brand-600') expect(button).toHaveClass('text-text-primary') }) it('should apply inactive styles when isActive is false', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -150,7 +112,6 @@ describe('Tab', () => { />, ) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('text-text-tertiary') expect(button).toHaveClass('border-transparent') @@ -159,11 +120,9 @@ describe('Tab', () => { describe('label prop', () => { it('should display the provided label text', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -174,16 +133,13 @@ describe('Tab', () => { />, ) - // Assert expect(screen.getByText('Custom Label Text')).toBeInTheDocument() }) it('should handle empty label', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -194,18 +150,15 @@ describe('Tab', () => { />, ) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() expect(screen.getByRole('button')).toHaveTextContent('') }) it('should handle long label text', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() const longLabel = 'This is a very long label text for testing purposes' - // Act render( <Tab isActive={false} @@ -216,19 +169,16 @@ describe('Tab', () => { />, ) - // Assert expect(screen.getByText(longLabel)).toBeInTheDocument() }) }) describe('value prop', () => { it('should pass value to onClick handler when clicked', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() const testValue = 'CUSTOM_VALUE' - // Act render( <Tab isActive={false} @@ -240,18 +190,15 @@ describe('Tab', () => { ) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnClick).toHaveBeenCalledWith(testValue) }) }) describe('workflowRunningData prop', () => { it('should enable button when workflowRunningData is provided', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -262,15 +209,12 @@ describe('Tab', () => { />, ) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) it('should disable button when workflowRunningData is undefined', () => { - // Arrange const mockOnClick = vi.fn() - // Act render( <Tab isActive={false} @@ -281,15 +225,12 @@ describe('Tab', () => { />, ) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should apply disabled styles when workflowRunningData is undefined', () => { - // Arrange const mockOnClick = vi.fn() - // Act render( <Tab isActive={false} @@ -300,18 +241,15 @@ describe('Tab', () => { />, ) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('!cursor-not-allowed') expect(button).toHaveClass('opacity-30') }) it('should not have disabled styles when workflowRunningData is provided', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -322,7 +260,6 @@ describe('Tab', () => { />, ) - // Assert const button = screen.getByRole('button') expect(button).not.toHaveClass('!cursor-not-allowed') expect(button).not.toHaveClass('opacity-30') @@ -330,16 +267,11 @@ describe('Tab', () => { }) }) - // ------------------------------------------------------------------------- - // Event Handlers Tests - Verify click behavior - // ------------------------------------------------------------------------- describe('Event Handlers', () => { it('should call onClick with value when clicked', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -351,16 +283,13 @@ describe('Tab', () => { ) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnClick).toHaveBeenCalledTimes(1) expect(mockOnClick).toHaveBeenCalledWith('RESULT') }) it('should not call onClick when disabled (no workflowRunningData)', () => { - // Arrange const mockOnClick = vi.fn() - // Act render( <Tab isActive={false} @@ -372,16 +301,13 @@ describe('Tab', () => { ) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnClick).not.toHaveBeenCalled() }) it('should handle multiple clicks correctly', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -396,17 +322,12 @@ describe('Tab', () => { fireEvent.click(button) fireEvent.click(button) - // Assert expect(mockOnClick).toHaveBeenCalledTimes(3) }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - Verify React.memo optimization - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should not re-render when props are the same', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() const renderSpy = vi.fn() @@ -417,7 +338,6 @@ describe('Tab', () => { } const MemoizedTabWithSpy = React.memo(TabWithSpy) - // Act const { rerender } = render( <MemoizedTabWithSpy isActive={false} @@ -428,7 +348,6 @@ describe('Tab', () => { />, ) - // Re-render with same props rerender( <MemoizedTabWithSpy isActive={false} @@ -439,16 +358,13 @@ describe('Tab', () => { />, ) - // Assert - React.memo should prevent re-render with same props expect(renderSpy).toHaveBeenCalledTimes(1) }) it('should re-render when isActive prop changes', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act const { rerender } = render( <Tab isActive={false} @@ -459,10 +375,8 @@ describe('Tab', () => { />, ) - // Assert initial state expect(screen.getByRole('button')).toHaveClass('text-text-tertiary') - // Rerender with changed prop rerender( <Tab isActive={true} @@ -473,16 +387,13 @@ describe('Tab', () => { />, ) - // Assert updated state expect(screen.getByRole('button')).toHaveClass('text-text-primary') }) it('should re-render when label prop changes', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act const { rerender } = render( <Tab isActive={false} @@ -493,10 +404,8 @@ describe('Tab', () => { />, ) - // Assert initial state expect(screen.getByText('Original Label')).toBeInTheDocument() - // Rerender with changed prop rerender( <Tab isActive={false} @@ -507,17 +416,14 @@ describe('Tab', () => { />, ) - // Assert updated state expect(screen.getByText('Updated Label')).toBeInTheDocument() expect(screen.queryByText('Original Label')).not.toBeInTheDocument() }) it('should use stable handleClick callback with useCallback', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act const { rerender } = render( <Tab isActive={false} @@ -531,7 +437,6 @@ describe('Tab', () => { fireEvent.click(screen.getByRole('button')) expect(mockOnClick).toHaveBeenCalledWith('TEST_VALUE') - // Rerender with same value and onClick rerender( <Tab isActive={true} @@ -548,17 +453,12 @@ describe('Tab', () => { }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - Verify boundary conditions - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle special characters in label', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() const specialLabel = 'Tab <>&"\'' - // Act render( <Tab isActive={false} @@ -569,16 +469,13 @@ describe('Tab', () => { />, ) - // Assert expect(screen.getByText(specialLabel)).toBeInTheDocument() }) it('should handle special characters in value', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -590,16 +487,13 @@ describe('Tab', () => { ) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnClick).toHaveBeenCalledWith('SPECIAL_VALUE_123') }) it('should handle unicode in label', () => { - // Arrange const mockOnClick = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tab isActive={false} @@ -610,15 +504,12 @@ describe('Tab', () => { />, ) - // Assert expect(screen.getByText('结果 🚀')).toBeInTheDocument() }) it('should combine isActive and disabled states correctly', () => { - // Arrange const mockOnClick = vi.fn() - // Act - Active but disabled (no workflowRunningData) render( <Tab isActive={true} @@ -629,7 +520,6 @@ describe('Tab', () => { />, ) - // Assert const button = screen.getByRole('button') expect(button).toBeDisabled() expect(button).toHaveClass('border-util-colors-blue-brand-blue-brand-600') @@ -639,25 +529,16 @@ describe('Tab', () => { }) }) -// ============================================================================ -// Tabs Component Tests -// ============================================================================ - describe('Tabs', () => { beforeEach(() => { vi.clearAllMocks() }) - // ------------------------------------------------------------------------- - // Rendering Tests - Verify basic component rendering - // ------------------------------------------------------------------------- describe('Rendering', () => { it('should render all three tabs', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="RESULT" @@ -666,18 +547,15 @@ describe('Tabs', () => { />, ) - // Assert - Check all three tabs are rendered with i18n keys expect(screen.getByRole('button', { name: 'runLog.result' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'runLog.detail' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'runLog.tracing' })).toBeInTheDocument() }) it('should render container with correct styles', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act const { container } = render( <Tabs currentTab="RESULT" @@ -686,7 +564,6 @@ describe('Tabs', () => { />, ) - // Assert const tabsContainer = container.firstChild expect(tabsContainer).toHaveClass('flex') expect(tabsContainer).toHaveClass('shrink-0') @@ -698,11 +575,9 @@ describe('Tabs', () => { }) it('should render exactly three tab buttons', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="RESULT" @@ -711,23 +586,17 @@ describe('Tabs', () => { />, ) - // Assert const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(3) }) }) - // ------------------------------------------------------------------------- - // Props Tests - Verify different prop combinations - // ------------------------------------------------------------------------- describe('Props', () => { describe('currentTab prop', () => { it('should set RESULT tab as active when currentTab is RESULT', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="RESULT" @@ -736,7 +605,6 @@ describe('Tabs', () => { />, ) - // Assert const resultTab = screen.getByRole('button', { name: 'runLog.result' }) const detailTab = screen.getByRole('button', { name: 'runLog.detail' }) const tracingTab = screen.getByRole('button', { name: 'runLog.tracing' }) @@ -747,11 +615,9 @@ describe('Tabs', () => { }) it('should set DETAIL tab as active when currentTab is DETAIL', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="DETAIL" @@ -760,7 +626,6 @@ describe('Tabs', () => { />, ) - // Assert const resultTab = screen.getByRole('button', { name: 'runLog.result' }) const detailTab = screen.getByRole('button', { name: 'runLog.detail' }) const tracingTab = screen.getByRole('button', { name: 'runLog.tracing' }) @@ -771,11 +636,9 @@ describe('Tabs', () => { }) it('should set TRACING tab as active when currentTab is TRACING', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="TRACING" @@ -784,7 +647,6 @@ describe('Tabs', () => { />, ) - // Assert const resultTab = screen.getByRole('button', { name: 'runLog.result' }) const detailTab = screen.getByRole('button', { name: 'runLog.detail' }) const tracingTab = screen.getByRole('button', { name: 'runLog.tracing' }) @@ -795,11 +657,9 @@ describe('Tabs', () => { }) it('should handle unknown currentTab gracefully', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="UNKNOWN" @@ -808,7 +668,6 @@ describe('Tabs', () => { />, ) - // Assert - All tabs should be inactive const resultTab = screen.getByRole('button', { name: 'runLog.result' }) const detailTab = screen.getByRole('button', { name: 'runLog.detail' }) const tracingTab = screen.getByRole('button', { name: 'runLog.tracing' }) @@ -821,11 +680,9 @@ describe('Tabs', () => { describe('workflowRunningData prop', () => { it('should enable all tabs when workflowRunningData is provided', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="RESULT" @@ -834,7 +691,6 @@ describe('Tabs', () => { />, ) - // Assert const buttons = screen.getAllByRole('button') buttons.forEach((button) => { expect(button).not.toBeDisabled() @@ -842,10 +698,8 @@ describe('Tabs', () => { }) it('should disable all tabs when workflowRunningData is undefined', () => { - // Arrange const mockSwitchTab = vi.fn() - // Act render( <Tabs currentTab="RESULT" @@ -854,7 +708,6 @@ describe('Tabs', () => { />, ) - // Assert const buttons = screen.getAllByRole('button') buttons.forEach((button) => { expect(button).toBeDisabled() @@ -863,11 +716,9 @@ describe('Tabs', () => { }) it('should pass workflowRunningData to all Tab components', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="RESULT" @@ -876,7 +727,6 @@ describe('Tabs', () => { />, ) - // Assert - All tabs should be enabled (workflowRunningData passed) const buttons = screen.getAllByRole('button') buttons.forEach((button) => { expect(button).not.toHaveClass('opacity-30') @@ -886,11 +736,9 @@ describe('Tabs', () => { describe('switchTab prop', () => { it('should pass switchTab function to Tab onClick', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="RESULT" @@ -900,22 +748,16 @@ describe('Tabs', () => { ) fireEvent.click(screen.getByRole('button', { name: 'runLog.detail' })) - // Assert expect(mockSwitchTab).toHaveBeenCalledWith('DETAIL') }) }) }) - // ------------------------------------------------------------------------- - // Event Handlers Tests - Verify click behavior - // ------------------------------------------------------------------------- describe('Event Handlers', () => { it('should call switchTab with RESULT when RESULT tab is clicked', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="DETAIL" @@ -925,16 +767,13 @@ describe('Tabs', () => { ) fireEvent.click(screen.getByRole('button', { name: 'runLog.result' })) - // Assert expect(mockSwitchTab).toHaveBeenCalledWith('RESULT') }) it('should call switchTab with DETAIL when DETAIL tab is clicked', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="RESULT" @@ -944,16 +783,13 @@ describe('Tabs', () => { ) fireEvent.click(screen.getByRole('button', { name: 'runLog.detail' })) - // Assert expect(mockSwitchTab).toHaveBeenCalledWith('DETAIL') }) it('should call switchTab with TRACING when TRACING tab is clicked', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="RESULT" @@ -963,15 +799,12 @@ describe('Tabs', () => { ) fireEvent.click(screen.getByRole('button', { name: 'runLog.tracing' })) - // Assert expect(mockSwitchTab).toHaveBeenCalledWith('TRACING') }) it('should not call switchTab when tabs are disabled', () => { - // Arrange const mockSwitchTab = vi.fn() - // Act render( <Tabs currentTab="RESULT" @@ -985,16 +818,13 @@ describe('Tabs', () => { fireEvent.click(button) }) - // Assert expect(mockSwitchTab).not.toHaveBeenCalled() }) it('should allow clicking the currently active tab', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="RESULT" @@ -1004,17 +834,12 @@ describe('Tabs', () => { ) fireEvent.click(screen.getByRole('button', { name: 'runLog.result' })) - // Assert expect(mockSwitchTab).toHaveBeenCalledWith('RESULT') }) }) - // ------------------------------------------------------------------------- - // Memoization Tests - Verify React.memo optimization - // ------------------------------------------------------------------------- describe('Memoization', () => { it('should not re-render when props are the same', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() const renderSpy = vi.fn() @@ -1025,7 +850,6 @@ describe('Tabs', () => { } const MemoizedTabsWithSpy = React.memo(TabsWithSpy) - // Act const { rerender } = render( <MemoizedTabsWithSpy currentTab="RESULT" @@ -1034,7 +858,6 @@ describe('Tabs', () => { />, ) - // Re-render with same props rerender( <MemoizedTabsWithSpy currentTab="RESULT" @@ -1043,16 +866,13 @@ describe('Tabs', () => { />, ) - // Assert - React.memo should prevent re-render with same props expect(renderSpy).toHaveBeenCalledTimes(1) }) it('should re-render when currentTab changes', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act const { rerender } = render( <Tabs currentTab="RESULT" @@ -1061,10 +881,8 @@ describe('Tabs', () => { />, ) - // Assert initial state expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass('text-text-primary') - // Rerender with changed prop rerender( <Tabs currentTab="DETAIL" @@ -1073,17 +891,14 @@ describe('Tabs', () => { />, ) - // Assert updated state expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass('text-text-tertiary') expect(screen.getByRole('button', { name: 'runLog.detail' })).toHaveClass('text-text-primary') }) it('should re-render when workflowRunningData changes from undefined to defined', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act const { rerender } = render( <Tabs currentTab="RESULT" @@ -1092,13 +907,11 @@ describe('Tabs', () => { />, ) - // Assert initial disabled state const buttons = screen.getAllByRole('button') buttons.forEach((button) => { expect(button).toBeDisabled() }) - // Rerender with workflowRunningData rerender( <Tabs currentTab="RESULT" @@ -1107,7 +920,6 @@ describe('Tabs', () => { />, ) - // Assert enabled state const updatedButtons = screen.getAllByRole('button') updatedButtons.forEach((button) => { expect(button).not.toBeDisabled() @@ -1115,16 +927,11 @@ describe('Tabs', () => { }) }) - // ------------------------------------------------------------------------- - // Edge Cases Tests - Verify boundary conditions - // ------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty string currentTab', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="" @@ -1133,7 +940,6 @@ describe('Tabs', () => { />, ) - // Assert - All tabs should be inactive const buttons = screen.getAllByRole('button') buttons.forEach((button) => { expect(button).toHaveClass('text-text-tertiary') @@ -1141,11 +947,9 @@ describe('Tabs', () => { }) it('should handle case-sensitive tab values', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act - lowercase "result" should not match "RESULT" render( <Tabs currentTab="result" @@ -1154,16 +958,13 @@ describe('Tabs', () => { />, ) - // Assert - Result tab should not be active (case mismatch) expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass('text-text-tertiary') }) it('should handle whitespace in currentTab', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab=" RESULT " @@ -1172,12 +973,10 @@ describe('Tabs', () => { />, ) - // Assert - Should not match due to whitespace expect(screen.getByRole('button', { name: 'runLog.result' })).toHaveClass('text-text-tertiary') }) it('should render correctly with minimal workflowRunningData', () => { - // Arrange const mockSwitchTab = vi.fn() const minimalWorkflowData: WorkflowRunningData = { result: { @@ -1188,7 +987,6 @@ describe('Tabs', () => { }, } - // Act render( <Tabs currentTab="RESULT" @@ -1197,7 +995,6 @@ describe('Tabs', () => { />, ) - // Assert const buttons = screen.getAllByRole('button') buttons.forEach((button) => { expect(button).not.toBeDisabled() @@ -1205,11 +1002,9 @@ describe('Tabs', () => { }) it('should maintain tab order (RESULT, DETAIL, TRACING)', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="RESULT" @@ -1218,7 +1013,6 @@ describe('Tabs', () => { />, ) - // Assert const buttons = screen.getAllByRole('button') expect(buttons[0]).toHaveTextContent('runLog.result') expect(buttons[1]).toHaveTextContent('runLog.detail') @@ -1226,16 +1020,11 @@ describe('Tabs', () => { }) }) - // ------------------------------------------------------------------------- - // Integration Tests - Verify Tab and Tabs work together - // ------------------------------------------------------------------------- describe('Integration', () => { it('should correctly pass all props to child Tab components', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act render( <Tabs currentTab="DETAIL" @@ -1244,22 +1033,18 @@ describe('Tabs', () => { />, ) - // Assert - Verify each tab has correct props const resultTab = screen.getByRole('button', { name: 'runLog.result' }) const detailTab = screen.getByRole('button', { name: 'runLog.detail' }) const tracingTab = screen.getByRole('button', { name: 'runLog.tracing' }) - // Check active states expect(resultTab).toHaveClass('text-text-tertiary') expect(detailTab).toHaveClass('text-text-primary') expect(tracingTab).toHaveClass('text-text-tertiary') - // Check enabled states expect(resultTab).not.toBeDisabled() expect(detailTab).not.toBeDisabled() expect(tracingTab).not.toBeDisabled() - // Check click handlers fireEvent.click(resultTab) expect(mockSwitchTab).toHaveBeenCalledWith('RESULT') @@ -1268,12 +1053,10 @@ describe('Tabs', () => { }) it('should support full tab switching workflow', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() let currentTab = 'RESULT' - // Act const { rerender } = render( <Tabs currentTab={currentTab} @@ -1282,11 +1065,9 @@ describe('Tabs', () => { />, ) - // Simulate clicking DETAIL tab fireEvent.click(screen.getByRole('button', { name: 'runLog.detail' })) expect(mockSwitchTab).toHaveBeenCalledWith('DETAIL') - // Update currentTab and rerender (simulating parent state update) currentTab = 'DETAIL' rerender( <Tabs @@ -1296,14 +1077,11 @@ describe('Tabs', () => { />, ) - // Assert DETAIL is now active expect(screen.getByRole('button', { name: 'runLog.detail' })).toHaveClass('text-text-primary') - // Simulate clicking TRACING tab fireEvent.click(screen.getByRole('button', { name: 'runLog.tracing' })) expect(mockSwitchTab).toHaveBeenCalledWith('TRACING') - // Update currentTab and rerender currentTab = 'TRACING' rerender( <Tabs @@ -1313,16 +1091,13 @@ describe('Tabs', () => { />, ) - // Assert TRACING is now active expect(screen.getByRole('button', { name: 'runLog.tracing' })).toHaveClass('text-text-primary') }) it('should transition from disabled to enabled state', () => { - // Arrange const mockSwitchTab = vi.fn() const workflowData = createWorkflowRunningData() - // Act - Initial disabled state const { rerender } = render( <Tabs currentTab="RESULT" @@ -1331,11 +1106,9 @@ describe('Tabs', () => { />, ) - // Try clicking - should not trigger fireEvent.click(screen.getByRole('button', { name: 'runLog.detail' })) expect(mockSwitchTab).not.toHaveBeenCalled() - // Enable tabs rerender( <Tabs currentTab="RESULT" @@ -1344,7 +1117,6 @@ describe('Tabs', () => { />, ) - // Now click should work fireEvent.click(screen.getByRole('button', { name: 'runLog.detail' })) expect(mockSwitchTab).toHaveBeenCalledWith('DETAIL') }) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx similarity index 84% rename from web/app/components/rag-pipeline/components/rag-pipeline-header/index.spec.tsx rename to web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx index 4f3465a920..0b858eaaa7 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx @@ -3,21 +3,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { WorkflowRunningStatus } from '@/app/components/workflow/types' -// ============================================================================ -// Import Components After Mocks -// ============================================================================ +import RagPipelineHeader from '../index' +import InputFieldButton from '../input-field-button' +import Publisher from '../publisher' +import Popup from '../publisher/popup' +import RunMode from '../run-mode' -import RagPipelineHeader from './index' -import InputFieldButton from './input-field-button' -import Publisher from './publisher' -import Popup from './publisher/popup' -import RunMode from './run-mode' - -// ============================================================================ -// Mock External Dependencies -// ============================================================================ - -// Mock workflow store const mockSetShowInputFieldPanel = vi.fn() const mockSetShowEnvPanel = vi.fn() const mockSetIsPreparingDataSource = vi.fn() @@ -51,7 +42,6 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock workflow hooks const mockHandleSyncWorkflowDraft = vi.fn() const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true) const mockHandleStopRun = vi.fn() @@ -72,7 +62,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({ }), })) -// Mock Header component vi.mock('@/app/components/workflow/header', () => ({ default: ({ normal, viewHistory }: { normal?: { components?: { left?: ReactNode, middle?: ReactNode }, runAndHistoryProps?: unknown } @@ -87,21 +76,18 @@ vi.mock('@/app/components/workflow/header', () => ({ ), })) -// Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ push: mockPush }), })) -// Mock next/link vi.mock('next/link', () => ({ default: ({ children, href, ...props }: PropsWithChildren<{ href: string }>) => ( <a href={href} {...props}>{children}</a> ), })) -// Mock service hooks const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: Date.now() }) const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({}) @@ -127,7 +113,6 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ useInvalidDatasetList: () => vi.fn(), })) -// Mock context hooks const mockMutateDatasetRes = vi.fn() vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: () => mockMutateDatasetRes, @@ -145,7 +130,6 @@ vi.mock('@/context/provider-context', () => ({ selector(mockProviderContextValue), })) -// Mock event emitter context const mockEventEmitter = { useSubscription: vi.fn(), } @@ -156,7 +140,6 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -// Mock hooks vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: () => '/api/docs', })) @@ -167,12 +150,10 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ }), })) -// Mock amplitude tracking vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -// Mock toast context const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ useToastContext: () => ({ @@ -180,13 +161,11 @@ vi.mock('@/app/components/base/toast', () => ({ }), })) -// Mock workflow utils vi.mock('@/app/components/workflow/utils', () => ({ getKeyboardKeyCodeBySystem: (key: string) => key, getKeyboardKeyNameBySystem: (key: string) => key, })) -// Mock ahooks vi.mock('ahooks', () => ({ useBoolean: (initial: boolean) => { let value = initial @@ -202,7 +181,6 @@ vi.mock('ahooks', () => ({ useKeyPress: vi.fn(), })) -// Mock portal components - keep actual behavior for open state let portalOpenState = false vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: PropsWithChildren<{ @@ -224,8 +202,7 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ }, })) -// Mock PublishAsKnowledgePipelineModal -vi.mock('../../publish-as-knowledge-pipeline-modal', () => ({ +vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ default: ({ onConfirm, onCancel }: { onConfirm: (name: string, icon: unknown, description?: string) => void onCancel: () => void @@ -238,10 +215,6 @@ vi.mock('../../publish-as-knowledge-pipeline-modal', () => ({ ), })) -// ============================================================================ -// Test Suites -// ============================================================================ - describe('RagPipelineHeader', () => { beforeEach(() => { vi.clearAllMocks() @@ -259,9 +232,6 @@ describe('RagPipelineHeader', () => { mockProviderContextValue = createMockProviderContextValue() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render(<RagPipelineHeader />) @@ -286,19 +256,14 @@ describe('RagPipelineHeader', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should compute viewHistoryProps based on pipelineId', () => { - // Test with first pipelineId mockStoreState.pipelineId = 'pipeline-alpha' const { unmount } = render(<RagPipelineHeader />) let viewHistoryContent = screen.getByTestId('header-view-history').textContent expect(viewHistoryContent).toContain('pipeline-alpha') unmount() - // Test with different pipelineId mockStoreState.pipelineId = 'pipeline-beta' render(<RagPipelineHeader />) viewHistoryContent = screen.getByTestId('header-view-history').textContent @@ -320,9 +285,6 @@ describe('InputFieldButton', () => { mockStoreState.setShowEnvPanel = mockSetShowEnvPanel }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render button with correct text', () => { render(<InputFieldButton />) @@ -337,9 +299,6 @@ describe('InputFieldButton', () => { }) }) - // -------------------------------------------------------------------------- - // Event Handler Tests - // -------------------------------------------------------------------------- describe('Event Handlers', () => { it('should call setShowInputFieldPanel(true) when clicked', () => { render(<InputFieldButton />) @@ -367,16 +326,12 @@ describe('InputFieldButton', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle undefined setShowInputFieldPanel gracefully', () => { mockStoreState.setShowInputFieldPanel = undefined as unknown as typeof mockSetShowInputFieldPanel render(<InputFieldButton />) - // Should not throw when clicked expect(() => fireEvent.click(screen.getByRole('button'))).not.toThrow() }) }) @@ -388,9 +343,6 @@ describe('Publisher', () => { portalOpenState = false }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render publish button', () => { render(<Publisher />) @@ -410,9 +362,6 @@ describe('Publisher', () => { }) }) - // -------------------------------------------------------------------------- - // Interaction Tests - // -------------------------------------------------------------------------- describe('Interactions', () => { it('should call handleSyncWorkflowDraft when opening', () => { render(<Publisher />) @@ -430,7 +379,6 @@ describe('Publisher', () => { fireEvent.click(screen.getByTestId('portal-trigger')) - // After click, handleOpenChange should be called expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled() }) }) @@ -447,9 +395,6 @@ describe('Popup', () => { }) }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render popup container', () => { render(<Popup />) @@ -475,7 +420,6 @@ describe('Popup', () => { it('should render keyboard shortcuts', () => { render(<Popup />) - // Should show the keyboard shortcut keys expect(screen.getByText('ctrl')).toBeInTheDocument() expect(screen.getByText('⇧')).toBeInTheDocument() expect(screen.getByText('P')).toBeInTheDocument() @@ -500,9 +444,6 @@ describe('Popup', () => { }) }) - // -------------------------------------------------------------------------- - // Button State Tests - // -------------------------------------------------------------------------- describe('Button States', () => { it('should disable goToAddDocuments when not published', () => { mockStoreState.publishedAt = 0 @@ -532,9 +473,6 @@ describe('Popup', () => { }) }) - // -------------------------------------------------------------------------- - // Premium Badge Tests - // -------------------------------------------------------------------------- describe('Premium Badge', () => { it('should show premium badge when not allowed to publish as template', () => { mockProviderContextValue = createMockProviderContextValue({ @@ -557,9 +495,6 @@ describe('Popup', () => { }) }) - // -------------------------------------------------------------------------- - // Interaction Tests - // -------------------------------------------------------------------------- describe('Interactions', () => { it('should call handleCheckBeforePublish when publish button clicked', async () => { render(<Popup />) @@ -598,9 +533,6 @@ describe('Popup', () => { }) }) - // -------------------------------------------------------------------------- - // Auto-save Display Tests - // -------------------------------------------------------------------------- describe('Auto-save Display', () => { it('should show auto-saved time when not published', () => { mockStoreState.publishedAt = 0 @@ -629,9 +561,6 @@ describe('RunMode', () => { mockEventEmitterEnabled = true }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render run button with default text', () => { render(<RunMode />) @@ -654,9 +583,6 @@ describe('RunMode', () => { }) }) - // -------------------------------------------------------------------------- - // Running State Tests - // -------------------------------------------------------------------------- describe('Running States', () => { it('should show processing state when running', () => { mockStoreState.workflowRunningData = { @@ -677,7 +603,6 @@ describe('RunMode', () => { render(<RunMode />) - // There should be two buttons: run button and stop button const buttons = screen.getAllByRole('button') expect(buttons.length).toBe(2) }) @@ -751,7 +676,6 @@ describe('RunMode', () => { render(<RunMode />) - // Should only have one button (run button) const buttons = screen.getAllByRole('button') expect(buttons.length).toBe(1) }) @@ -781,9 +705,6 @@ describe('RunMode', () => { }) }) - // -------------------------------------------------------------------------- - // Disabled State Tests - // -------------------------------------------------------------------------- describe('Disabled States', () => { it('should be disabled when running', () => { mockStoreState.workflowRunningData = { @@ -818,9 +739,6 @@ describe('RunMode', () => { }) }) - // -------------------------------------------------------------------------- - // Interaction Tests - // -------------------------------------------------------------------------- describe('Interactions', () => { it('should call handleWorkflowStartRunInWorkflow when clicked', () => { render(<RunMode />) @@ -838,7 +756,6 @@ describe('RunMode', () => { render(<RunMode />) - // Click the stop button (second button) const buttons = screen.getAllByRole('button') fireEvent.click(buttons[1]) @@ -850,7 +767,6 @@ describe('RunMode', () => { render(<RunMode />) - // Click the cancel button (second button) const buttons = screen.getAllByRole('button') fireEvent.click(buttons[1]) @@ -883,14 +799,10 @@ describe('RunMode', () => { const runButton = screen.getAllByRole('button')[0] fireEvent.click(runButton) - // Should not be called because button is disabled expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled() }) }) - // -------------------------------------------------------------------------- - // Event Emitter Tests - // -------------------------------------------------------------------------- describe('Event Emitter', () => { it('should subscribe to event emitter', () => { render(<RunMode />) @@ -904,7 +816,6 @@ describe('RunMode', () => { result: { status: WorkflowRunningStatus.Running }, } - // Capture the subscription callback let subscriptionCallback: ((v: { type: string }) => void) | null = null mockEventEmitter.useSubscription.mockImplementation((callback: (v: { type: string }) => void) => { subscriptionCallback = callback @@ -912,7 +823,6 @@ describe('RunMode', () => { render(<RunMode />) - // Simulate the EVENT_WORKFLOW_STOP event (actual value is 'WORKFLOW_STOP') expect(subscriptionCallback).not.toBeNull() subscriptionCallback!({ type: 'WORKFLOW_STOP' }) @@ -932,7 +842,6 @@ describe('RunMode', () => { render(<RunMode />) - // Simulate a different event type subscriptionCallback!({ type: 'some_other_event' }) expect(mockHandleStopRun).not.toHaveBeenCalled() @@ -941,7 +850,6 @@ describe('RunMode', () => { it('should handle undefined eventEmitter gracefully', () => { mockEventEmitterEnabled = false - // Should not throw when eventEmitter is undefined expect(() => render(<RunMode />)).not.toThrow() }) @@ -951,14 +859,10 @@ describe('RunMode', () => { render(<RunMode />) - // useSubscription should not be called expect(mockEventEmitter.useSubscription).not.toHaveBeenCalled() }) }) - // -------------------------------------------------------------------------- - // Style Tests - // -------------------------------------------------------------------------- describe('Styles', () => { it('should have rounded-md class when not disabled', () => { render(<RunMode />) @@ -1053,21 +957,13 @@ describe('RunMode', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped in React.memo', () => { - // RunMode is exported as default from run-mode.tsx with React.memo - // We can verify it's memoized by checking the component's $$typeof symbol expect((RunMode as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) }) }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ describe('Integration', () => { beforeEach(() => { vi.clearAllMocks() @@ -1087,10 +983,8 @@ describe('Integration', () => { it('should render all child components in RagPipelineHeader', () => { render(<RagPipelineHeader />) - // InputFieldButton expect(screen.getByText(/inputField/i)).toBeInTheDocument() - // Publisher (via header-middle slot) expect(screen.getByTestId('header-middle')).toBeInTheDocument() }) @@ -1104,9 +998,6 @@ describe('Integration', () => { }) }) -// ============================================================================ -// Edge Cases -// ============================================================================ describe('Edge Cases', () => { beforeEach(() => { vi.clearAllMocks() @@ -1136,20 +1027,17 @@ describe('Edge Cases', () => { result: undefined as unknown as { status: WorkflowRunningStatus }, } - // Component will crash when accessing result.status - this documents current behavior expect(() => render(<RunMode />)).toThrow() }) }) describe('RunMode Edge Cases', () => { beforeEach(() => { - // Ensure clean state for each test mockStoreState.workflowRunningData = null mockStoreState.isPreparingDataSource = false }) it('should handle both isPreparingDataSource and isRunning being true', () => { - // This shouldn't happen in practice, but test the priority mockStoreState.isPreparingDataSource = true mockStoreState.workflowRunningData = { task_id: 'task-123', @@ -1158,7 +1046,6 @@ describe('Edge Cases', () => { render(<RunMode />) - // Button should be disabled const runButton = screen.getAllByRole('button')[0] expect(runButton).toBeDisabled() }) @@ -1169,7 +1056,6 @@ describe('Edge Cases', () => { render(<RunMode />) - // Verify the button is enabled and shows testRun text const button = screen.getByRole('button') expect(button).not.toBeDisabled() expect(button.textContent).toContain('pipeline.common.testRun') @@ -1193,7 +1079,6 @@ describe('Edge Cases', () => { render(<RunMode text="Start Pipeline" />) - // Should show reRun, not custom text const button = screen.getByRole('button') expect(button.textContent).toContain('pipeline.common.reRun') expect(screen.queryByText('Start Pipeline')).not.toBeInTheDocument() @@ -1205,7 +1090,6 @@ describe('Edge Cases', () => { render(<RunMode />) - // Verify keyboard shortcut elements exist expect(screen.getByText('alt')).toBeInTheDocument() expect(screen.getByText('R')).toBeInTheDocument() }) @@ -1216,7 +1100,6 @@ describe('Edge Cases', () => { render(<RunMode />) - // Should have svg icon in the button const button = screen.getByRole('button') expect(button.querySelector('svg')).toBeInTheDocument() }) @@ -1229,7 +1112,6 @@ describe('Edge Cases', () => { render(<RunMode />) - // Should have animate-spin class on the loader icon const runButton = screen.getAllByRole('button')[0] const spinningIcon = runButton.querySelector('.animate-spin') expect(spinningIcon).toBeInTheDocument() @@ -1252,7 +1134,6 @@ describe('Edge Cases', () => { render(<Popup />) - // Should render without crashing expect(screen.getByText(/workflow.common.autoSaved/i)).toBeInTheDocument() }) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx new file mode 100644 index 0000000000..9ac47aae02 --- /dev/null +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import RunMode from '../run-mode' + +const mockHandleWorkflowStartRunInWorkflow = vi.fn() +const mockHandleStopRun = vi.fn() +const mockSetIsPreparingDataSource = vi.fn() +const mockSetShowDebugAndPreviewPanel = vi.fn() + +let mockWorkflowRunningData: { task_id: string, result: { status: string } } | undefined +let mockIsPreparingDataSource = false +vi.mock('@/app/components/workflow/hooks', () => ({ + useWorkflowRun: () => ({ + handleStopRun: mockHandleStopRun, + }), + useWorkflowStartRun: () => ({ + handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow, + }), +})) + +vi.mock('@/app/components/workflow/shortcuts-name', () => ({ + default: ({ keys }: { keys: string[] }) => <span data-testid="shortcuts">{keys.join('+')}</span>, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: Record<string, unknown>) => unknown) => { + const state = { + workflowRunningData: mockWorkflowRunningData, + isPreparingDataSource: mockIsPreparingDataSource, + } + return selector(state) + }, + useWorkflowStore: () => ({ + getState: () => ({ + setIsPreparingDataSource: mockSetIsPreparingDataSource, + setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel, + }), + }), +})) + +vi.mock('@/app/components/workflow/types', () => ({ + WorkflowRunningStatus: { Running: 'running' }, +})) + +vi.mock('@/app/components/workflow/variable-inspect/types', () => ({ + EVENT_WORKFLOW_STOP: 'EVENT_WORKFLOW_STOP', +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { useSubscription: vi.fn() }, + }), +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: unknown[]) => args.filter(a => typeof a === 'string').join(' '), +})) + +vi.mock('@remixicon/react', () => ({ + RiCloseLine: () => <span data-testid="close-icon" />, + RiDatabase2Line: () => <span data-testid="database-icon" />, + RiLoader2Line: () => <span data-testid="loader-icon" />, + RiPlayLargeLine: () => <span data-testid="play-icon" />, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({ + StopCircle: () => <span data-testid="stop-icon" />, +})) + +describe('RunMode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWorkflowRunningData = undefined + mockIsPreparingDataSource = false + }) + + describe('Idle state', () => { + it('should render test run text when no data', () => { + render(<RunMode />) + + expect(screen.getByText('pipeline.common.testRun')).toBeInTheDocument() + }) + + it('should render custom text when provided', () => { + render(<RunMode text="Custom Run" />) + + expect(screen.getByText('Custom Run')).toBeInTheDocument() + }) + + it('should render play icon', () => { + render(<RunMode />) + + expect(screen.getByTestId('play-icon')).toBeInTheDocument() + }) + + it('should render keyboard shortcuts', () => { + render(<RunMode />) + + expect(screen.getByTestId('shortcuts')).toBeInTheDocument() + }) + + it('should call start run when button clicked', () => { + render(<RunMode />) + + fireEvent.click(screen.getByText('pipeline.common.testRun')) + + expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalled() + }) + }) + + describe('Running state', () => { + beforeEach(() => { + mockWorkflowRunningData = { + task_id: 'task-1', + result: { status: 'running' }, + } + }) + + it('should show processing text', () => { + render(<RunMode />) + + expect(screen.getByText('pipeline.common.processing')).toBeInTheDocument() + }) + + it('should show stop button', () => { + render(<RunMode />) + + expect(screen.getByTestId('stop-icon')).toBeInTheDocument() + }) + + it('should disable run button', () => { + render(<RunMode />) + + const button = screen.getByText('pipeline.common.processing').closest('button') + expect(button).toBeDisabled() + }) + + it('should call handleStopRun with task_id when stop clicked', () => { + render(<RunMode />) + + fireEvent.click(screen.getByTestId('stop-icon').closest('button')!) + + expect(mockHandleStopRun).toHaveBeenCalledWith('task-1') + }) + }) + + describe('After run completed', () => { + it('should show reRun text when previous run data exists', () => { + mockWorkflowRunningData = { + task_id: 'task-1', + result: { status: 'succeeded' }, + } + render(<RunMode />) + + expect(screen.getByText('pipeline.common.reRun')).toBeInTheDocument() + }) + }) + + describe('Preparing data source state', () => { + beforeEach(() => { + mockIsPreparingDataSource = true + }) + + it('should show preparing text', () => { + render(<RunMode />) + + expect(screen.getByText('pipeline.common.preparingDataSource')).toBeInTheDocument() + }) + + it('should show database icon', () => { + render(<RunMode />) + + expect(screen.getByTestId('database-icon')).toBeInTheDocument() + }) + + it('should show cancel button with close icon', () => { + render(<RunMode />) + + expect(screen.getByTestId('close-icon')).toBeInTheDocument() + }) + + it('should cancel preparing when close clicked', () => { + render(<RunMode />) + + fireEvent.click(screen.getByTestId('close-icon').closest('button')!) + + expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false) + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(false) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx similarity index 81% rename from web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.spec.tsx rename to web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index 2a01218ee6..0fc3bda7b3 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -3,29 +3,21 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Publisher from './index' -import Popup from './popup' +import Publisher from '../index' +import Popup from '../popup' -// ================================ -// Mock External Dependencies Only -// ================================ - -// Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ push: mockPush }), })) -// Mock next/link vi.mock('next/link', () => ({ default: ({ children, href, ...props }: { children: React.ReactNode, href: string }) => ( <a href={href} {...props}>{children}</a> ), })) -// Mock ahooks -// Store the keyboard shortcut callback for testing let keyPressCallback: ((e: KeyboardEvent) => void) | null = null vi.mock('ahooks', () => ({ useBoolean: (defaultValue = false) => { @@ -37,17 +29,14 @@ vi.mock('ahooks', () => ({ }] }, useKeyPress: (key: string, callback: (e: KeyboardEvent) => void) => { - // Store the callback so we can invoke it in tests keyPressCallback = callback }, })) -// Mock amplitude tracking vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -// Mock portal-to-follow-elem let mockPortalOpen = false vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: { @@ -76,7 +65,6 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ }, })) -// Mock workflow hooks const mockHandleSyncWorkflowDraft = vi.fn() const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true) vi.mock('@/app/components/workflow/hooks', () => ({ @@ -88,7 +76,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({ }), })) -// Mock workflow store const mockPublishedAt = vi.fn(() => null as number | null) const mockDraftUpdatedAt = vi.fn(() => 1700000000) const mockPipelineId = vi.fn(() => 'test-pipeline-id') @@ -110,7 +97,6 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock dataset-detail context const mockMutateDatasetRes = vi.fn() vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: (selector: (s: Record<string, unknown>) => unknown) => { @@ -119,13 +105,11 @@ vi.mock('@/context/dataset-detail', () => ({ }, })) -// Mock modal-context const mockSetShowPricingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ useModalContextSelector: () => mockSetShowPricingModal, })) -// Mock provider-context const mockIsAllowPublishAsCustomKnowledgePipelineTemplate = vi.fn(() => true) vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ @@ -135,7 +119,6 @@ vi.mock('@/context/provider-context', () => ({ selector({ isAllowPublishAsCustomKnowledgePipelineTemplate: mockIsAllowPublishAsCustomKnowledgePipelineTemplate() }), })) -// Mock toast context const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ useToastContext: () => ({ @@ -143,12 +126,10 @@ vi.mock('@/app/components/base/toast', () => ({ }), })) -// Mock API access URL hook vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: () => 'https://api.dify.ai/v1/datasets/test-dataset-id', })) -// Mock format time hook vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: (timestamp: number) => { @@ -162,7 +143,6 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ }), })) -// Mock service hooks const mockPublishWorkflow = vi.fn() const mockPublishAsCustomizedPipeline = vi.fn() const mockInvalidPublishedPipelineInfo = vi.fn() @@ -191,14 +171,12 @@ vi.mock('@/service/use-workflow', () => ({ }), })) -// Mock workflow utils vi.mock('@/app/components/workflow/utils', () => ({ getKeyboardKeyCodeBySystem: (key: string) => key, getKeyboardKeyNameBySystem: (key: string) => key === 'ctrl' ? '⌘' : key, })) -// Mock PublishAsKnowledgePipelineModal -vi.mock('../../publish-as-knowledge-pipeline-modal', () => ({ +vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ default: ({ confirmDisabled, onConfirm, onCancel }: { confirmDisabled: boolean onConfirm: (name: string, icon: IconInfo, description?: string) => void @@ -217,10 +195,6 @@ vi.mock('../../publish-as-knowledge-pipeline-modal', () => ({ ), })) -// ================================ -// Test Data Factories -// ================================ - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { @@ -238,16 +212,11 @@ const renderWithQueryClient = (ui: React.ReactElement) => { ) } -// ================================ -// Test Suites -// ================================ - describe('publisher', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpen = false keyPressCallback = null - // Reset mock return values to defaults mockPublishedAt.mockReturnValue(null) mockDraftUpdatedAt.mockReturnValue(1700000000) mockPipelineId.mockReturnValue('test-pipeline-id') @@ -255,127 +224,90 @@ describe('publisher', () => { mockHandleCheckBeforePublish.mockResolvedValue(true) }) - // ============================================================ - // Publisher (index.tsx) - Main Entry Component Tests - // ============================================================ describe('Publisher (index.tsx)', () => { - // -------------------------------- - // Rendering Tests - // -------------------------------- describe('Rendering', () => { it('should render publish button with correct text', () => { - // Arrange & Act renderWithQueryClient(<Publisher />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() expect(screen.getByText('workflow.common.publish')).toBeInTheDocument() }) it('should render portal element in closed state by default', () => { - // Arrange & Act renderWithQueryClient(<Publisher />) - // Assert expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() }) it('should render down arrow icon in button', () => { - // Arrange & Act renderWithQueryClient(<Publisher />) - // Assert const button = screen.getByRole('button') expect(button.querySelector('svg')).toBeInTheDocument() }) }) - // -------------------------------- - // State Management Tests - // -------------------------------- describe('State Management', () => { it('should open popup when trigger is clicked', async () => { - // Arrange renderWithQueryClient(<Publisher />) - // Act fireEvent.click(screen.getByTestId('portal-trigger')) - // Assert await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) }) it('should close popup when trigger is clicked again while open', async () => { - // Arrange renderWithQueryClient(<Publisher />) fireEvent.click(screen.getByTestId('portal-trigger')) // open - // Act await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('portal-trigger')) // close - // Assert await waitFor(() => { expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() }) }) }) - // -------------------------------- - // Callback Stability and Memoization Tests - // -------------------------------- describe('Callback Stability and Memoization', () => { it('should call handleSyncWorkflowDraft when popup opens', async () => { - // Arrange renderWithQueryClient(<Publisher />) - // Act fireEvent.click(screen.getByTestId('portal-trigger')) - // Assert expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true) }) it('should not call handleSyncWorkflowDraft when popup closes', async () => { - // Arrange renderWithQueryClient(<Publisher />) fireEvent.click(screen.getByTestId('portal-trigger')) // open vi.clearAllMocks() - // Act await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('portal-trigger')) // close - // Assert expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() }) it('should be memoized with React.memo', () => { - // Assert expect(Publisher).toBeDefined() expect((Publisher as unknown as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) - // -------------------------------- - // User Interactions Tests - // -------------------------------- describe('User Interactions', () => { it('should render popup content when opened', async () => { - // Arrange renderWithQueryClient(<Publisher />) - // Act fireEvent.click(screen.getByTestId('portal-trigger')) - // Assert await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) @@ -383,68 +315,48 @@ describe('publisher', () => { }) }) - // ============================================================ - // Popup (popup.tsx) - Main Popup Component Tests - // ============================================================ describe('Popup (popup.tsx)', () => { - // -------------------------------- - // Rendering Tests - // -------------------------------- describe('Rendering', () => { it('should render unpublished state when publishedAt is null', () => { - // Arrange mockPublishedAt.mockReturnValue(null) - // Act renderWithQueryClient(<Popup />) - // Assert expect(screen.getByText('workflow.common.currentDraftUnpublished')).toBeInTheDocument() expect(screen.getByText(/workflow.common.autoSaved/)).toBeInTheDocument() }) it('should render published state when publishedAt has value', () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) - // Act renderWithQueryClient(<Popup />) - // Assert expect(screen.getByText('workflow.common.latestPublished')).toBeInTheDocument() expect(screen.getByText(/workflow.common.publishedAt/)).toBeInTheDocument() }) it('should render publish button with keyboard shortcuts', () => { - // Arrange & Act renderWithQueryClient(<Popup />) - // Assert const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) expect(publishButton).toBeInTheDocument() }) it('should render action buttons section', () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) - // Act renderWithQueryClient(<Popup />) - // Assert expect(screen.getByText('pipeline.common.goToAddDocuments')).toBeInTheDocument() expect(screen.getByText('workflow.common.accessAPIReference')).toBeInTheDocument() expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument() }) it('should disable action buttons when not published', () => { - // Arrange mockPublishedAt.mockReturnValue(null) - // Act renderWithQueryClient(<Popup />) - // Assert const addDocumentsButton = screen.getAllByRole('button').find(btn => btn.textContent?.includes('pipeline.common.goToAddDocuments'), ) @@ -452,13 +364,10 @@ describe('publisher', () => { }) it('should enable action buttons when published', () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) - // Act renderWithQueryClient(<Popup />) - // Assert const addDocumentsButton = screen.getAllByRole('button').find(btn => btn.textContent?.includes('pipeline.common.goToAddDocuments'), ) @@ -466,137 +375,106 @@ describe('publisher', () => { }) it('should show premium badge when publish as template is not allowed', () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(false) - // Act renderWithQueryClient(<Popup />) - // Assert expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() }) it('should not show premium badge when publish as template is allowed', () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true) - // Act renderWithQueryClient(<Popup />) - // Assert expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() }) }) - // -------------------------------- - // State Management Tests - // -------------------------------- describe('State Management', () => { it('should show confirm modal when first publish attempt on unpublished pipeline', async () => { - // Arrange mockPublishedAt.mockReturnValue(null) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert await waitFor(() => { expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() }) }) it('should not show confirm modal when already published', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert - should call publish directly without confirm await waitFor(() => { expect(mockPublishWorkflow).toHaveBeenCalled() }) }) it('should update to published state after successful publish', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert await waitFor(() => { expect(screen.getByRole('button', { name: /workflow.common.published/i })).toBeInTheDocument() }) }) }) - // -------------------------------- - // User Interactions Tests - // -------------------------------- describe('User Interactions', () => { it('should navigate to add documents when go to add documents is clicked', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) renderWithQueryClient(<Popup />) - // Act const addDocumentsButton = screen.getAllByRole('button').find(btn => btn.textContent?.includes('pipeline.common.goToAddDocuments'), ) fireEvent.click(addDocumentsButton!) - // Assert expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/documents/create-from-pipeline') }) it('should show pricing modal when publish as template is clicked without permission', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(false) renderWithQueryClient(<Popup />) - // Act const publishAsButton = screen.getAllByRole('button').find(btn => btn.textContent?.includes('pipeline.common.publishAs'), ) fireEvent.click(publishAsButton!) - // Assert expect(mockSetShowPricingModal).toHaveBeenCalled() }) it('should show publish as knowledge pipeline modal when permitted', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true) renderWithQueryClient(<Popup />) - // Act const publishAsButton = screen.getAllByRole('button').find(btn => btn.textContent?.includes('pipeline.common.publishAs'), ) fireEvent.click(publishAsButton!) - // Assert await waitFor(() => { expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument() }) }) it('should close publish as knowledge pipeline modal when cancel is clicked', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true) renderWithQueryClient(<Popup />) @@ -610,17 +488,14 @@ describe('publisher', () => { expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument() }) - // Act fireEvent.click(screen.getByTestId('modal-cancel')) - // Assert await waitFor(() => { expect(screen.queryByTestId('publish-as-knowledge-pipeline-modal')).not.toBeInTheDocument() }) }) it('should call publishAsCustomizedPipeline when confirm is clicked in modal', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishAsCustomizedPipeline.mockResolvedValue({}) renderWithQueryClient(<Popup />) @@ -634,10 +509,8 @@ describe('publisher', () => { expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument() }) - // Act fireEvent.click(screen.getByTestId('modal-confirm')) - // Assert await waitFor(() => { expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({ pipelineId: 'test-pipeline-id', @@ -649,21 +522,15 @@ describe('publisher', () => { }) }) - // -------------------------------- - // API Calls and Async Operations Tests - // -------------------------------- describe('API Calls and Async Operations', () => { it('should call publishWorkflow API when publish button is clicked', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert await waitFor(() => { expect(mockPublishWorkflow).toHaveBeenCalledWith({ url: '/rag/pipelines/test-pipeline-id/workflows/publish', @@ -674,16 +541,13 @@ describe('publisher', () => { }) it('should show success notification after publish', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -695,32 +559,26 @@ describe('publisher', () => { }) it('should update publishedAt in store after successful publish', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert await waitFor(() => { expect(mockSetPublishedAt).toHaveBeenCalledWith(1700100000) }) }) it('should invalidate caches after successful publish', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert await waitFor(() => { expect(mockMutateDatasetRes).toHaveBeenCalled() expect(mockInvalidPublishedPipelineInfo).toHaveBeenCalled() @@ -729,7 +587,6 @@ describe('publisher', () => { }) it('should show success notification for publish as template', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishAsCustomizedPipeline.mockResolvedValue({}) renderWithQueryClient(<Popup />) @@ -743,10 +600,8 @@ describe('publisher', () => { expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument() }) - // Act fireEvent.click(screen.getByTestId('modal-confirm')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -758,7 +613,6 @@ describe('publisher', () => { }) it('should invalidate customized template list after publish as template', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishAsCustomizedPipeline.mockResolvedValue({}) renderWithQueryClient(<Popup />) @@ -772,31 +626,23 @@ describe('publisher', () => { expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument() }) - // Act fireEvent.click(screen.getByTestId('modal-confirm')) - // Assert await waitFor(() => { expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled() }) }) }) - // -------------------------------- - // Error Handling Tests - // -------------------------------- describe('Error Handling', () => { it('should not proceed with publish when check fails', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockHandleCheckBeforePublish.mockResolvedValue(false) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert - publishWorkflow should not be called when check fails await waitFor(() => { expect(mockHandleCheckBeforePublish).toHaveBeenCalled() }) @@ -804,16 +650,13 @@ describe('publisher', () => { }) it('should show error notification when publish fails', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockRejectedValue(new Error('Publish failed')) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', @@ -823,7 +666,6 @@ describe('publisher', () => { }) it('should show error notification when publish as template fails', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishAsCustomizedPipeline.mockRejectedValue(new Error('Template publish failed')) renderWithQueryClient(<Popup />) @@ -837,10 +679,8 @@ describe('publisher', () => { expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument() }) - // Act fireEvent.click(screen.getByTestId('modal-confirm')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', @@ -850,7 +690,6 @@ describe('publisher', () => { }) it('should close modal after publish as template error', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishAsCustomizedPipeline.mockRejectedValue(new Error('Template publish failed')) renderWithQueryClient(<Popup />) @@ -864,22 +703,16 @@ describe('publisher', () => { expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument() }) - // Act fireEvent.click(screen.getByTestId('modal-confirm')) - // Assert await waitFor(() => { expect(screen.queryByTestId('publish-as-knowledge-pipeline-modal')).not.toBeInTheDocument() }) }) }) - // -------------------------------- - // Confirm Modal Tests - // -------------------------------- describe('Confirm Modal', () => { it('should hide confirm modal when cancel is clicked', async () => { - // Arrange mockPublishedAt.mockReturnValue(null) renderWithQueryClient(<Popup />) @@ -890,7 +723,6 @@ describe('publisher', () => { expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() }) - // Act - find and click cancel button in confirm modal const cancelButtons = screen.getAllByRole('button') const cancelButton = cancelButtons.find(btn => btn.className.includes('cancel') || btn.textContent?.includes('Cancel'), @@ -898,16 +730,11 @@ describe('publisher', () => { if (cancelButton) fireEvent.click(cancelButton) - // Trigger onCancel manually since we can't find the exact button - // The Confirm component has an onCancel prop that calls hideConfirm - - // Assert - modal should be dismissable // Note: This test verifies the confirm modal can be displayed expect(screen.getByText('pipeline.common.confirmPublishContent')).toBeInTheDocument() }) it('should publish when confirm is clicked in confirm modal', async () => { - // Arrange mockPublishedAt.mockReturnValue(null) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) @@ -919,28 +746,19 @@ describe('publisher', () => { expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() }) - // Assert - confirm modal content is displayed expect(screen.getByText('pipeline.common.confirmPublishContent')).toBeInTheDocument() }) }) - // -------------------------------- - // Component Memoization Tests - // -------------------------------- describe('Component Memoization', () => { it('should be memoized with React.memo', () => { - // Assert expect(Popup).toBeDefined() expect((Popup as unknown as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol') }) }) - // -------------------------------- - // Prop Variations Tests - // -------------------------------- describe('Prop Variations', () => { it('should display correct width when permission is allowed', () => { - // Test with permission mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true) const { container } = renderWithQueryClient(<Popup />) @@ -949,7 +767,6 @@ describe('publisher', () => { }) it('should display correct width when permission is not allowed', () => { - // Test without permission mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(false) const { container } = renderWithQueryClient(<Popup />) @@ -958,63 +775,45 @@ describe('publisher', () => { }) it('should display draft updated time when not published', () => { - // Arrange mockPublishedAt.mockReturnValue(null) mockDraftUpdatedAt.mockReturnValue(1700000000) - // Act renderWithQueryClient(<Popup />) - // Assert expect(screen.getByText(/workflow.common.autoSaved/)).toBeInTheDocument() }) it('should handle null draftUpdatedAt gracefully', () => { - // Arrange mockPublishedAt.mockReturnValue(null) mockDraftUpdatedAt.mockReturnValue(0) - // Act renderWithQueryClient(<Popup />) - // Assert expect(screen.getByText(/workflow.common.autoSaved/)).toBeInTheDocument() }) }) - // -------------------------------- - // API Reference Link Tests - // -------------------------------- describe('API Reference Link', () => { it('should render API reference link with correct href', () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) - // Act renderWithQueryClient(<Popup />) - // Assert const apiLink = screen.getByRole('link') expect(apiLink).toHaveAttribute('href', 'https://api.dify.ai/v1/datasets/test-dataset-id') expect(apiLink).toHaveAttribute('target', '_blank') }) }) - // -------------------------------- - // Keyboard Shortcut Tests - // -------------------------------- describe('Keyboard Shortcuts', () => { it('should trigger publish when keyboard shortcut is pressed', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Act - simulate keyboard shortcut const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent keyPressCallback?.(mockEvent) - // Assert expect(mockEvent.preventDefault).toHaveBeenCalled() await waitFor(() => { expect(mockPublishWorkflow).toHaveBeenCalled() @@ -1022,12 +821,10 @@ describe('publisher', () => { }) it('should not trigger publish when already published in session', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // First publish via button click to set published state const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) @@ -1037,32 +834,26 @@ describe('publisher', () => { vi.clearAllMocks() - // Act - simulate keyboard shortcut after already published const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent keyPressCallback?.(mockEvent) - // Assert - should return early without publishing expect(mockEvent.preventDefault).toHaveBeenCalled() expect(mockPublishWorkflow).not.toHaveBeenCalled() }) it('should show confirm modal when shortcut pressed on unpublished pipeline', async () => { - // Arrange mockPublishedAt.mockReturnValue(null) renderWithQueryClient(<Popup />) - // Act - simulate keyboard shortcut const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent keyPressCallback?.(mockEvent) - // Assert await waitFor(() => { expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() }) }) it('should not trigger duplicate publish via shortcut when already publishing', async () => { - // Arrange - create a promise that we can control let resolvePublish: () => void = () => {} mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockImplementation(() => new Promise((resolve) => { @@ -1070,59 +861,45 @@ describe('publisher', () => { })) renderWithQueryClient(<Popup />) - // Act - trigger publish via keyboard shortcut first const mockEvent1 = { preventDefault: vi.fn() } as unknown as KeyboardEvent keyPressCallback?.(mockEvent1) - // Wait for the first publish to start (button becomes disabled) await waitFor(() => { const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) expect(publishButton).toBeDisabled() }) - // Try to trigger again via shortcut while publishing const mockEvent2 = { preventDefault: vi.fn() } as unknown as KeyboardEvent keyPressCallback?.(mockEvent2) - // Assert - only one call to publishWorkflow expect(mockPublishWorkflow).toHaveBeenCalledTimes(1) - // Cleanup - resolve the promise resolvePublish() }) }) - // -------------------------------- - // Finally Block Cleanup Tests - // -------------------------------- describe('Finally Block Cleanup', () => { it('should reset publishing state after successful publish', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert - button should be disabled during publishing, then show published await waitFor(() => { expect(screen.getByRole('button', { name: /workflow.common.published/i })).toBeInTheDocument() }) }) it('should reset publishing state after failed publish', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockRejectedValue(new Error('Publish failed')) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert - should show error and button should be enabled again (not showing "published") await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', @@ -1130,19 +907,16 @@ describe('publisher', () => { }) }) - // Button should still show publishUpdate since it wasn't successfully published await waitFor(() => { expect(screen.getByRole('button', { name: /workflow.common.publishUpdate/i })).toBeInTheDocument() }) }) it('should hide confirm modal after publish from confirm', async () => { - // Arrange mockPublishedAt.mockReturnValue(null) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Show confirm modal first const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) @@ -1150,25 +924,18 @@ describe('publisher', () => { expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() }) - // Act - trigger publish again (which happens when confirm is clicked) - // The mock for workflow hooks returns handleCheckBeforePublish that resolves to true - // We need to simulate the confirm button click which calls handlePublish again - // Since confirmVisible is now true and publishedAt is null, it should proceed to publish fireEvent.click(publishButton) - // Assert - confirm modal should be hidden after publish completes await waitFor(() => { expect(screen.getByRole('button', { name: /workflow.common.published/i })).toBeInTheDocument() }) }) it('should hide confirm modal after failed publish', async () => { - // Arrange mockPublishedAt.mockReturnValue(null) mockPublishWorkflow.mockRejectedValue(new Error('Publish failed')) renderWithQueryClient(<Popup />) - // Show confirm modal first const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) @@ -1176,10 +943,8 @@ describe('publisher', () => { expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() }) - // Act - trigger publish from confirm (call handlePublish when confirmVisible is true) fireEvent.click(publishButton) - // Assert - error notification should be shown await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', @@ -1190,137 +955,104 @@ describe('publisher', () => { }) }) - // ============================================================ - // Edge Cases - // ============================================================ describe('Edge Cases', () => { it('should handle undefined pipelineId gracefully', () => { - // Arrange mockPipelineId.mockReturnValue('') - // Act renderWithQueryClient(<Popup />) - // Assert - should render without crashing expect(screen.getByText('workflow.common.currentDraftUnpublished')).toBeInTheDocument() }) it('should handle empty publish response', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue(null) renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert - should not call setPublishedAt or notify when response is null await waitFor(() => { expect(mockPublishWorkflow).toHaveBeenCalled() }) - // setPublishedAt should not be called because res is falsy expect(mockSetPublishedAt).not.toHaveBeenCalled() }) it('should prevent multiple simultaneous publish calls', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) - // Create a promise that never resolves to simulate ongoing publish mockPublishWorkflow.mockImplementation(() => new Promise(() => {})) renderWithQueryClient(<Popup />) - // Act - click publish button multiple times rapidly const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Wait for button to become disabled await waitFor(() => { expect(publishButton).toBeDisabled() }) - // Try clicking again fireEvent.click(publishButton) fireEvent.click(publishButton) - // Assert - publishWorkflow should only be called once due to guard expect(mockPublishWorkflow).toHaveBeenCalledTimes(1) }) it('should disable publish button when already published in session', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Act - publish once const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert - button should show "published" state await waitFor(() => { expect(screen.getByRole('button', { name: /workflow.common.published/i })).toBeDisabled() }) }) it('should not trigger publish when already publishing', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishWorkflow.mockImplementation(() => new Promise(() => {})) // Never resolves renderWithQueryClient(<Popup />) - // Act const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // The button should be disabled while publishing await waitFor(() => { expect(publishButton).toBeDisabled() }) }) }) - // ============================================================ - // Integration Tests - // ============================================================ describe('Integration Tests', () => { it('should complete full publish flow for unpublished pipeline', async () => { - // Arrange mockPublishedAt.mockReturnValue(null) mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - // Act - click publish to show confirm const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) fireEvent.click(publishButton) - // Assert - confirm modal should appear await waitFor(() => { expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() }) }) it('should complete full publish as template flow', async () => { - // Arrange mockPublishedAt.mockReturnValue(1700000000) mockPublishAsCustomizedPipeline.mockResolvedValue({}) renderWithQueryClient(<Popup />) - // Act - click publish as template button const publishAsButton = screen.getAllByRole('button').find(btn => btn.textContent?.includes('pipeline.common.publishAs'), ) fireEvent.click(publishAsButton!) - // Assert - modal should appear await waitFor(() => { expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument() }) - // Act - confirm fireEvent.click(screen.getByTestId('modal-confirm')) - // Assert - success notification and modal closes await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -1332,18 +1064,14 @@ describe('publisher', () => { }) it('should show Publisher button and open popup with Popup component', async () => { - // Arrange & Act renderWithQueryClient(<Publisher />) - // Click to open popup fireEvent.click(screen.getByTestId('portal-trigger')) - // Assert await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) - // Verify sync was called when opening expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true) }) }) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx new file mode 100644 index 0000000000..71707721a4 --- /dev/null +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -0,0 +1,319 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import Popup from '../popup' + +const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' }) +const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({}) +const mockNotify = vi.fn() +const mockPush = vi.fn() +const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true) +const mockSetPublishedAt = vi.fn() +const mockMutateDatasetRes = vi.fn() +const mockSetShowPricingModal = vi.fn() +const mockInvalidPublishedPipelineInfo = vi.fn() +const mockInvalidDatasetList = vi.fn() +const mockInvalidCustomizedTemplateList = vi.fn() + +let mockPublishedAt: string | undefined = '2024-01-01T00:00:00Z' +let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z' +let mockPipelineId: string | undefined = 'pipeline-123' +let mockIsAllowPublishAsCustom = true +vi.mock('next/navigation', () => ({ + useParams: () => ({ datasetId: 'ds-123' }), + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode, href: string }) => ( + <a href={href}>{children}</a> + ), +})) + +vi.mock('ahooks', () => ({ + useBoolean: (initial: boolean) => { + const state = { value: initial } + return [state.value, { + setFalse: vi.fn(), + setTrue: vi.fn(), + }] + }, + useKeyPress: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: Record<string, unknown>) => unknown) => { + const state = { + publishedAt: mockPublishedAt, + draftUpdatedAt: mockDraftUpdatedAt, + pipelineId: mockPipelineId, + } + return selector(state) + }, + useWorkflowStore: () => ({ + getState: () => ({ + setPublishedAt: mockSetPublishedAt, + }), + }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: mockNotify }), +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, disabled, variant, className }: Record<string, unknown>) => ( + <button + onClick={onClick as () => void} + disabled={disabled as boolean} + data-variant={variant as string} + className={className as string} + > + {children as React.ReactNode} + </button> + ), +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ isShow, onConfirm, onCancel, title }: { + isShow: boolean + onConfirm: () => void + onCancel: () => void + title: string + }) => + isShow + ? ( + <div data-testid="confirm-modal"> + <span>{title}</span> + <button data-testid="publish-confirm" onClick={onConfirm}>OK</button> + <button data-testid="publish-cancel" onClick={onCancel}>Cancel</button> + </div> + ) + : null, +})) + +vi.mock('@/app/components/base/divider', () => ({ + default: () => <hr />, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +vi.mock('@/app/components/base/icons/src/public/common', () => ({ + SparklesSoft: () => <span data-testid="sparkles" />, +})) + +vi.mock('@/app/components/base/premium-badge', () => ({ + default: ({ children }: { children: React.ReactNode }) => <span data-testid="premium-badge">{children}</span>, +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useChecklistBeforePublish: () => ({ + handleCheckBeforePublish: mockHandleCheckBeforePublish, + }), +})) + +vi.mock('@/app/components/workflow/shortcuts-name', () => ({ + default: ({ keys }: { keys: string[] }) => <span data-testid="shortcuts">{keys.join('+')}</span>, +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + getKeyboardKeyCodeBySystem: () => 'ctrl', +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: () => mockMutateDatasetRes, +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => () => 'https://docs.dify.ai', +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContextSelector: () => mockSetShowPricingModal, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContextSelector: () => mockIsAllowPublishAsCustom, +})) + +vi.mock('@/hooks/use-api-access-url', () => ({ + useDatasetApiAccessUrl: () => '/api/datasets/ds-123', +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (time: string) => `formatted:${time}`, + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => mockInvalidDatasetList, +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: () => mockInvalidPublishedPipelineInfo, +})) + +vi.mock('@/service/use-pipeline', () => ({ + publishedPipelineInfoQueryKeyPrefix: ['published-pipeline'], + useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList, + usePublishAsCustomizedPipeline: () => ({ + mutateAsync: mockPublishAsCustomizedPipeline, + }), +})) + +vi.mock('@/service/use-workflow', () => ({ + usePublishWorkflow: () => ({ + mutateAsync: mockPublishWorkflow, + }), +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: string[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ + default: ({ onConfirm, onCancel }: { onConfirm: (name: string, icon: unknown, desc: string) => void, onCancel: () => void }) => ( + <div data-testid="publish-as-modal"> + <button data-testid="publish-as-confirm" onClick={() => onConfirm('My Pipeline', { icon_type: 'emoji' }, 'desc')}> + Confirm + </button> + <button data-testid="publish-as-cancel" onClick={onCancel}>Cancel</button> + </div> + ), +})) + +vi.mock('@remixicon/react', () => ({ + RiArrowRightUpLine: () => <span />, + RiHammerLine: () => <span />, + RiPlayCircleLine: () => <span />, + RiTerminalBoxLine: () => <span />, +})) + +describe('Popup', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPublishedAt = '2024-01-01T00:00:00Z' + mockDraftUpdatedAt = '2024-06-01T00:00:00Z' + mockPipelineId = 'pipeline-123' + mockIsAllowPublishAsCustom = true + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('Rendering', () => { + it('should render when published', () => { + render(<Popup />) + + expect(screen.getByText('workflow.common.latestPublished')).toBeInTheDocument() + expect(screen.getByText(/workflow\.common\.publishedAt/)).toBeInTheDocument() + }) + + it('should render unpublished state', () => { + mockPublishedAt = undefined + render(<Popup />) + + expect(screen.getByText('workflow.common.currentDraftUnpublished')).toBeInTheDocument() + expect(screen.getByText(/workflow\.common\.autoSaved/)).toBeInTheDocument() + }) + + it('should render publish button with shortcuts', () => { + render(<Popup />) + + expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument() + expect(screen.getByTestId('shortcuts')).toBeInTheDocument() + }) + + it('should render "Go to Add Documents" button', () => { + render(<Popup />) + + expect(screen.getByText('pipeline.common.goToAddDocuments')).toBeInTheDocument() + }) + + it('should render "API Reference" button', () => { + render(<Popup />) + + expect(screen.getByText('workflow.common.accessAPIReference')).toBeInTheDocument() + }) + + it('should render "Publish As" button', () => { + render(<Popup />) + + expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument() + }) + }) + + describe('Premium Badge', () => { + it('should not show premium badge when allowed', () => { + mockIsAllowPublishAsCustom = true + render(<Popup />) + + expect(screen.queryByTestId('premium-badge')).not.toBeInTheDocument() + }) + + it('should show premium badge when not allowed', () => { + mockIsAllowPublishAsCustom = false + render(<Popup />) + + expect(screen.getByTestId('premium-badge')).toBeInTheDocument() + }) + }) + + describe('Navigation', () => { + it('should navigate to add documents page', () => { + render(<Popup />) + + fireEvent.click(screen.getByText('pipeline.common.goToAddDocuments')) + + expect(mockPush).toHaveBeenCalledWith('/datasets/ds-123/documents/create-from-pipeline') + }) + }) + + describe('Button disable states', () => { + it('should disable add documents button when not published', () => { + mockPublishedAt = undefined + render(<Popup />) + + const btn = screen.getByText('pipeline.common.goToAddDocuments').closest('button') + expect(btn).toBeDisabled() + }) + + it('should disable publish-as button when not published', () => { + mockPublishedAt = undefined + render(<Popup />) + + const btn = screen.getByText('pipeline.common.publishAs').closest('button') + expect(btn).toBeDisabled() + }) + }) + + describe('Publish As Knowledge Pipeline', () => { + it('should show pricing modal when not allowed', () => { + mockIsAllowPublishAsCustom = false + render(<Popup />) + + fireEvent.click(screen.getByText('pipeline.common.publishAs')) + + expect(mockSetShowPricingModal).toHaveBeenCalled() + }) + }) + + describe('Time formatting', () => { + it('should format published time', () => { + render(<Popup />) + + expect(screen.getByText(/formatted:2024-01-01/)).toBeInTheDocument() + }) + + it('should format draft updated time when unpublished', () => { + mockPublishedAt = undefined + render(<Popup />) + + expect(screen.getByText(/formatted:2024-06-01/)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/index.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts similarity index 91% rename from web/app/components/rag-pipeline/hooks/index.spec.ts rename to web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts index 7917275c18..4c60e5133c 100644 --- a/web/app/components/rag-pipeline/hooks/index.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts @@ -6,10 +6,6 @@ import { BlockEnum } from '@/app/components/workflow/types' import { Resolution, TransferMethod } from '@/types/app' import { FlowType } from '@/types/common' -// ============================================================================ -// Import hooks after mocks -// ============================================================================ - import { useAvailableNodesMetaData, useDSL, @@ -20,16 +16,11 @@ import { usePipelineRefreshDraft, usePipelineRun, usePipelineStartRun, -} from './index' -import { useConfigsMap } from './use-configs-map' -import { useConfigurations, useInitialData } from './use-input-fields' -import { usePipelineTemplate } from './use-pipeline-template' +} from '../index' +import { useConfigsMap } from '../use-configs-map' +import { useConfigurations, useInitialData } from '../use-input-fields' +import { usePipelineTemplate } from '../use-pipeline-template' -// ============================================================================ -// Mocks -// ============================================================================ - -// Mock the workflow store const _mockGetState = vi.fn() const mockUseStore = vi.fn() const mockUseWorkflowStore = vi.fn() @@ -39,14 +30,6 @@ vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => mockUseWorkflowStore(), })) -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock toast context const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ useToastContext: () => ({ @@ -54,7 +37,6 @@ vi.mock('@/app/components/base/toast', () => ({ }), })) -// Mock event emitter context const mockEventEmit = vi.fn() vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ @@ -64,19 +46,16 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -// Mock i18n docLink vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, })) -// Mock workflow constants vi.mock('@/app/components/workflow/constants', () => ({ DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', START_INITIAL_POSITION: { x: 100, y: 100 }, })) -// Mock workflow constants/node vi.mock('@/app/components/workflow/constants/node', () => ({ WORKFLOW_COMMON_NODES: [ { @@ -90,7 +69,6 @@ vi.mock('@/app/components/workflow/constants/node', () => ({ ], })) -// Mock data source defaults vi.mock('@/app/components/workflow/nodes/data-source-empty/default', () => ({ default: { metaData: { type: BlockEnum.DataSourceEmpty }, @@ -112,7 +90,6 @@ vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({ }, })) -// Mock workflow utils with all needed exports vi.mock('@/app/components/workflow/utils', async (importOriginal) => { const actual = await importOriginal() as Record<string, unknown> return { @@ -123,7 +100,6 @@ vi.mock('@/app/components/workflow/utils', async (importOriginal) => { } }) -// Mock pipeline service const mockExportPipelineConfig = vi.fn() vi.mock('@/service/use-pipeline', () => ({ useExportPipelineDSL: () => ({ @@ -131,7 +107,6 @@ vi.mock('@/service/use-pipeline', () => ({ }), })) -// Mock workflow service vi.mock('@/service/workflow', () => ({ fetchWorkflowDraft: vi.fn().mockResolvedValue({ graph: { nodes: [], edges: [], viewport: {} }, @@ -139,10 +114,6 @@ vi.mock('@/service/workflow', () => ({ }), })) -// ============================================================================ -// Tests -// ============================================================================ - describe('useConfigsMap', () => { beforeEach(() => { vi.clearAllMocks() @@ -307,11 +278,10 @@ describe('useInputFieldPanel', () => { it('should set edit panel props when toggleInputFieldEditPanel is called', () => { const { result } = renderHook(() => useInputFieldPanel()) - const editContent = { type: 'edit', data: {} } + const editContent = { onClose: vi.fn(), onSubmit: vi.fn() } act(() => { - // eslint-disable-next-line ts/no-explicit-any - result.current.toggleInputFieldEditPanel(editContent as any) + result.current.toggleInputFieldEditPanel(editContent) }) expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(editContent) diff --git a/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-DSL.spec.ts similarity index 90% rename from web/app/components/rag-pipeline/hooks/use-DSL.spec.ts rename to web/app/components/rag-pipeline/hooks/__tests__/use-DSL.spec.ts index 295ed20bd8..c0b983052d 100644 --- a/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-DSL.spec.ts @@ -1,8 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useDSL } from './use-DSL' +import { useDSL } from '../use-DSL' -// Mock dependencies const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ useToastContext: () => ({ notify: mockNotify }), @@ -14,7 +13,7 @@ vi.mock('@/context/event-emitter', () => ({ })) const mockDoSyncWorkflowDraft = vi.fn() -vi.mock('./use-nodes-sync-draft', () => ({ +vi.mock('../use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft }), })) @@ -37,21 +36,10 @@ const mockDownloadBlob = vi.fn() vi.mock('@/utils/download', () => ({ downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), })) - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - vi.mock('@/app/components/workflow/constants', () => ({ DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', })) -// ============================================================================ -// Tests -// ============================================================================ - describe('useDSL', () => { let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn>, style: { display: string }, remove: ReturnType<typeof vi.fn> } let originalCreateElement: typeof document.createElement @@ -62,7 +50,6 @@ describe('useDSL', () => { beforeEach(() => { vi.clearAllMocks() - // Create a proper mock link element with all required properties for downloadBlob mockLink = { href: '', download: '', @@ -71,7 +58,6 @@ describe('useDSL', () => { remove: vi.fn(), } - // Save original and mock selectively - only intercept 'a' elements originalCreateElement = document.createElement.bind(document) document.createElement = vi.fn((tagName: string) => { if (tagName === 'a') { @@ -80,15 +66,12 @@ describe('useDSL', () => { return originalCreateElement(tagName) }) as typeof document.createElement - // Mock document.body.appendChild for downloadBlob originalAppendChild = document.body.appendChild.bind(document.body) document.body.appendChild = vi.fn(<T extends Node>(node: T): T => node) as typeof document.body.appendChild - // downloadBlob uses window.URL, not URL mockCreateObjectURL = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:test-url') mockRevokeObjectURL = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {}) - // Default store state mockGetState.mockReturnValue({ pipelineId: 'test-pipeline-id', knowledgeName: 'Test Knowledge Base', @@ -170,7 +153,7 @@ describe('useDSL', () => { await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'exportFailed', + message: 'app.exportFailed', }) }) }) @@ -251,7 +234,7 @@ describe('useDSL', () => { await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'exportFailed', + message: 'app.exportFailed', }) }) }) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-available-nodes-meta-data.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-available-nodes-meta-data.spec.ts new file mode 100644 index 0000000000..f3d04533da --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-available-nodes-meta-data.spec.ts @@ -0,0 +1,130 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { BlockEnum } from '@/app/components/workflow/types' +import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data' + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path?: string) => `https://docs.dify.ai${path || ''}`, +})) + +vi.mock('@/app/components/workflow/constants/node', () => ({ + WORKFLOW_COMMON_NODES: [ + { + metaData: { type: BlockEnum.LLM }, + defaultValue: { title: 'LLM' }, + }, + { + metaData: { type: BlockEnum.HumanInput }, + defaultValue: { title: 'Human Input' }, + }, + { + metaData: { type: BlockEnum.HttpRequest }, + defaultValue: { title: 'HTTP Request' }, + }, + ], +})) + +vi.mock('@/app/components/workflow/nodes/data-source-empty/default', () => ({ + default: { + metaData: { type: BlockEnum.DataSourceEmpty }, + defaultValue: { title: 'Data Source Empty' }, + }, +})) + +vi.mock('@/app/components/workflow/nodes/data-source/default', () => ({ + default: { + metaData: { type: BlockEnum.DataSource }, + defaultValue: { title: 'Data Source' }, + }, +})) + +vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({ + default: { + metaData: { type: BlockEnum.KnowledgeBase }, + defaultValue: { title: 'Knowledge Base' }, + }, +})) + +describe('useAvailableNodesMetaData', () => { + it('should return nodes and nodesMap', () => { + const { result } = renderHook(() => useAvailableNodesMetaData()) + + expect(result.current.nodes).toBeDefined() + expect(result.current.nodesMap).toBeDefined() + }) + + it('should filter out HumanInput node', () => { + const { result } = renderHook(() => useAvailableNodesMetaData()) + const nodeTypes = result.current.nodes.map(n => n.metaData.type) + + expect(nodeTypes).not.toContain(BlockEnum.HumanInput) + }) + + it('should include DataSource with _dataSourceStartToAdd flag', () => { + const { result } = renderHook(() => useAvailableNodesMetaData()) + const dsNode = result.current.nodes.find(n => n.metaData.type === BlockEnum.DataSource) + + expect(dsNode).toBeDefined() + expect(dsNode!.defaultValue._dataSourceStartToAdd).toBe(true) + }) + + it('should include KnowledgeBase and DataSourceEmpty nodes', () => { + const { result } = renderHook(() => useAvailableNodesMetaData()) + const nodeTypes = result.current.nodes.map(n => n.metaData.type) + + expect(nodeTypes).toContain(BlockEnum.KnowledgeBase) + expect(nodeTypes).toContain(BlockEnum.DataSourceEmpty) + }) + + it('should translate title and description for each node', () => { + const { result } = renderHook(() => useAvailableNodesMetaData()) + + result.current.nodes.forEach((node) => { + expect(node.metaData.title).toMatch(/^workflow\.blocks\./) + expect(node.metaData.description).toMatch(/^workflow\.blocksAbout\./) + }) + }) + + it('should set helpLinkUri on each node metaData', () => { + const { result } = renderHook(() => useAvailableNodesMetaData()) + + result.current.nodes.forEach((node) => { + expect(node.metaData.helpLinkUri).toContain('https://docs.dify.ai') + expect(node.metaData.helpLinkUri).toContain('knowledge-pipeline') + }) + }) + + it('should set type and title on defaultValue', () => { + const { result } = renderHook(() => useAvailableNodesMetaData()) + + result.current.nodes.forEach((node) => { + expect(node.defaultValue.type).toBe(node.metaData.type) + expect(node.defaultValue.title).toBe(node.metaData.title) + }) + }) + + it('should build nodesMap indexed by BlockEnum type', () => { + const { result } = renderHook(() => useAvailableNodesMetaData()) + const { nodesMap } = result.current + + expect(nodesMap[BlockEnum.LLM]).toBeDefined() + expect(nodesMap[BlockEnum.DataSource]).toBeDefined() + expect(nodesMap[BlockEnum.KnowledgeBase]).toBeDefined() + }) + + it('should alias VariableAssigner to VariableAggregator in nodesMap', () => { + const { result } = renderHook(() => useAvailableNodesMetaData()) + const { nodesMap } = result.current + + expect(nodesMap[BlockEnum.VariableAssigner]).toBe(nodesMap[BlockEnum.VariableAggregator]) + }) + + it('should include common nodes except HumanInput', () => { + const { result } = renderHook(() => useAvailableNodesMetaData()) + const nodeTypes = result.current.nodes.map(n => n.metaData.type) + + expect(nodeTypes).toContain(BlockEnum.LLM) + expect(nodeTypes).toContain(BlockEnum.HttpRequest) + expect(nodeTypes).not.toContain(BlockEnum.HumanInput) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-configs-map.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-configs-map.spec.ts new file mode 100644 index 0000000000..6e5bedd122 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-configs-map.spec.ts @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { useConfigsMap } from '../use-configs-map' + +const mockPipelineId = 'pipeline-xyz' +const mockFileUploadConfig = { max_size: 10 } + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: Record<string, unknown>) => unknown) => { + const state = { + pipelineId: mockPipelineId, + fileUploadConfig: mockFileUploadConfig, + } + return selector(state) + }, +})) + +vi.mock('@/types/app', () => ({ + Resolution: { high: 'high' }, + TransferMethod: { local_file: 'local_file', remote_url: 'remote_url' }, +})) + +vi.mock('@/types/common', () => ({ + FlowType: { ragPipeline: 'rag-pipeline' }, +})) + +describe('useConfigsMap', () => { + it('should return flowId from pipelineId', () => { + const { result } = renderHook(() => useConfigsMap()) + + expect(result.current.flowId).toBe('pipeline-xyz') + }) + + it('should return ragPipeline as flowType', () => { + const { result } = renderHook(() => useConfigsMap()) + + expect(result.current.flowType).toBe('rag-pipeline') + }) + + it('should include file settings with image disabled', () => { + const { result } = renderHook(() => useConfigsMap()) + + expect(result.current.fileSettings.image.enabled).toBe(false) + }) + + it('should set image detail to high resolution', () => { + const { result } = renderHook(() => useConfigsMap()) + + expect(result.current.fileSettings.image.detail).toBe('high') + }) + + it('should set image number_limits to 3', () => { + const { result } = renderHook(() => useConfigsMap()) + + expect(result.current.fileSettings.image.number_limits).toBe(3) + }) + + it('should include both transfer methods for image', () => { + const { result } = renderHook(() => useConfigsMap()) + + expect(result.current.fileSettings.image.transfer_methods).toEqual(['local_file', 'remote_url']) + }) + + it('should pass through fileUploadConfig from store', () => { + const { result } = renderHook(() => useConfigsMap()) + + expect(result.current.fileSettings.fileUploadConfig).toEqual({ max_size: 10 }) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-get-run-and-trace-url.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-get-run-and-trace-url.spec.ts new file mode 100644 index 0000000000..10f31f55a6 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-get-run-and-trace-url.spec.ts @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { useGetRunAndTraceUrl } from '../use-get-run-and-trace-url' + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + pipelineId: 'pipeline-test-123', + }), + }), +})) + +describe('useGetRunAndTraceUrl', () => { + it('should return a function getWorkflowRunAndTraceUrl', () => { + const { result } = renderHook(() => useGetRunAndTraceUrl()) + + expect(typeof result.current.getWorkflowRunAndTraceUrl).toBe('function') + }) + + it('should generate correct runUrl', () => { + const { result } = renderHook(() => useGetRunAndTraceUrl()) + const { runUrl } = result.current.getWorkflowRunAndTraceUrl('run-abc') + + expect(runUrl).toBe('/rag/pipelines/pipeline-test-123/workflow-runs/run-abc') + }) + + it('should generate correct traceUrl', () => { + const { result } = renderHook(() => useGetRunAndTraceUrl()) + const { traceUrl } = result.current.getWorkflowRunAndTraceUrl('run-abc') + + expect(traceUrl).toBe('/rag/pipelines/pipeline-test-123/workflow-runs/run-abc/node-executions') + }) + + it('should handle different runIds', () => { + const { result } = renderHook(() => useGetRunAndTraceUrl()) + + const r1 = result.current.getWorkflowRunAndTraceUrl('id-1') + const r2 = result.current.getWorkflowRunAndTraceUrl('id-2') + + expect(r1.runUrl).toContain('id-1') + expect(r2.runUrl).toContain('id-2') + expect(r1.runUrl).not.toBe(r2.runUrl) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-input-field-panel.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-input-field-panel.spec.ts new file mode 100644 index 0000000000..d8c335e489 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-input-field-panel.spec.ts @@ -0,0 +1,130 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useInputFieldPanel } from '../use-input-field-panel' + +const mockSetShowInputFieldPanel = vi.fn() +const mockSetShowInputFieldPreviewPanel = vi.fn() +const mockSetInputFieldEditPanelProps = vi.fn() + +let mockShowInputFieldPreviewPanel = false +let mockInputFieldEditPanelProps: unknown = null + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + showInputFieldPreviewPanel: mockShowInputFieldPreviewPanel, + setShowInputFieldPanel: mockSetShowInputFieldPanel, + setShowInputFieldPreviewPanel: mockSetShowInputFieldPreviewPanel, + setInputFieldEditPanelProps: mockSetInputFieldEditPanelProps, + }), + }), + useStore: (selector: (state: Record<string, unknown>) => unknown) => { + const state = { + showInputFieldPreviewPanel: mockShowInputFieldPreviewPanel, + inputFieldEditPanelProps: mockInputFieldEditPanelProps, + } + return selector(state) + }, +})) + +describe('useInputFieldPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockShowInputFieldPreviewPanel = false + mockInputFieldEditPanelProps = null + }) + + describe('isPreviewing', () => { + it('should return false when preview panel is hidden', () => { + mockShowInputFieldPreviewPanel = false + const { result } = renderHook(() => useInputFieldPanel()) + + expect(result.current.isPreviewing).toBe(false) + }) + + it('should return true when preview panel is shown', () => { + mockShowInputFieldPreviewPanel = true + const { result } = renderHook(() => useInputFieldPanel()) + + expect(result.current.isPreviewing).toBe(true) + }) + }) + + describe('isEditing', () => { + it('should return false when no edit panel props', () => { + mockInputFieldEditPanelProps = null + const { result } = renderHook(() => useInputFieldPanel()) + + expect(result.current.isEditing).toBe(false) + }) + + it('should return true when edit panel props exist', () => { + mockInputFieldEditPanelProps = { onSubmit: vi.fn(), onClose: vi.fn() } + const { result } = renderHook(() => useInputFieldPanel()) + + expect(result.current.isEditing).toBe(true) + }) + }) + + describe('closeAllInputFieldPanels', () => { + it('should close all panels and clear edit props', () => { + const { result } = renderHook(() => useInputFieldPanel()) + + act(() => { + result.current.closeAllInputFieldPanels() + }) + + expect(mockSetShowInputFieldPanel).toHaveBeenCalledWith(false) + expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(false) + expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(null) + }) + }) + + describe('toggleInputFieldPreviewPanel', () => { + it('should toggle preview panel from false to true', () => { + mockShowInputFieldPreviewPanel = false + const { result } = renderHook(() => useInputFieldPanel()) + + act(() => { + result.current.toggleInputFieldPreviewPanel() + }) + + expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(true) + }) + + it('should toggle preview panel from true to false', () => { + mockShowInputFieldPreviewPanel = true + const { result } = renderHook(() => useInputFieldPanel()) + + act(() => { + result.current.toggleInputFieldPreviewPanel() + }) + + expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(false) + }) + }) + + describe('toggleInputFieldEditPanel', () => { + it('should set edit panel props when given content', () => { + const editContent = { onSubmit: vi.fn(), onClose: vi.fn() } + const { result } = renderHook(() => useInputFieldPanel()) + + act(() => { + result.current.toggleInputFieldEditPanel(editContent) + }) + + expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(editContent) + }) + + it('should clear edit panel props when given null', () => { + const { result } = renderHook(() => useInputFieldPanel()) + + act(() => { + result.current.toggleInputFieldEditPanel(null) + }) + + expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(null) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-input-fields.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-input-fields.spec.ts new file mode 100644 index 0000000000..ad6f97a2f4 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-input-fields.spec.ts @@ -0,0 +1,221 @@ +import type { RAGPipelineVariables } from '@/models/pipeline' +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' +import { useConfigurations, useInitialData } from '../use-input-fields' + +vi.mock('@/models/pipeline', () => ({ + VAR_TYPE_MAP: { + 'text-input': BaseFieldType.textInput, + 'paragraph': BaseFieldType.paragraph, + 'select': BaseFieldType.select, + 'number': BaseFieldType.numberInput, + 'checkbox': BaseFieldType.checkbox, + 'file': BaseFieldType.file, + 'file-list': BaseFieldType.fileList, + }, +})) + +const makeVariable = (overrides: Record<string, unknown> = {}) => ({ + variable: 'test_var', + label: 'Test Variable', + type: 'text-input', + required: true, + max_length: 100, + options: undefined, + placeholder: '', + tooltips: '', + unit: '', + default_value: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + allowed_file_upload_methods: undefined, + ...overrides, +}) + +describe('useInitialData', () => { + it('should initialize text-input with empty string by default', () => { + const variables = [makeVariable({ type: 'text-input' })] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useInitialData(variables)) + + expect(result.current.test_var).toBe('') + }) + + it('should initialize paragraph with empty string by default', () => { + const variables = [makeVariable({ type: 'paragraph', variable: 'para' })] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useInitialData(variables)) + + expect(result.current.para).toBe('') + }) + + it('should initialize select with empty string by default', () => { + const variables = [makeVariable({ type: 'select', variable: 'sel' })] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useInitialData(variables)) + + expect(result.current.sel).toBe('') + }) + + it('should initialize number with 0 by default', () => { + const variables = [makeVariable({ type: 'number', variable: 'num' })] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useInitialData(variables)) + + expect(result.current.num).toBe(0) + }) + + it('should initialize checkbox with false by default', () => { + const variables = [makeVariable({ type: 'checkbox', variable: 'cb' })] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useInitialData(variables)) + + expect(result.current.cb).toBe(false) + }) + + it('should initialize file with empty array by default', () => { + const variables = [makeVariable({ type: 'file', variable: 'f' })] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useInitialData(variables)) + + expect(result.current.f).toEqual([]) + }) + + it('should initialize file-list with empty array by default', () => { + const variables = [makeVariable({ type: 'file-list', variable: 'fl' })] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useInitialData(variables)) + + expect(result.current.fl).toEqual([]) + }) + + it('should use default_value from variable when available', () => { + const variables = [ + makeVariable({ type: 'text-input', default_value: 'hello' }), + ] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useInitialData(variables)) + + expect(result.current.test_var).toBe('hello') + }) + + it('should prefer lastRunInputData over default_value', () => { + const variables = [ + makeVariable({ type: 'text-input', default_value: 'default' }), + ] as unknown as RAGPipelineVariables + const lastRunInputData = { test_var: 'last-run-value' } + const { result } = renderHook(() => useInitialData(variables, lastRunInputData)) + + expect(result.current.test_var).toBe('last-run-value') + }) + + it('should handle multiple variables', () => { + const variables = [ + makeVariable({ type: 'text-input', variable: 'name', default_value: 'Alice' }), + makeVariable({ type: 'number', variable: 'age', default_value: 25 }), + makeVariable({ type: 'checkbox', variable: 'agree' }), + ] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useInitialData(variables)) + + expect(result.current.name).toBe('Alice') + expect(result.current.age).toBe(25) + expect(result.current.agree).toBe(false) + }) +}) + +describe('useConfigurations', () => { + it('should convert variables to BaseConfiguration format', () => { + const variables = [ + makeVariable({ + type: 'text-input', + variable: 'name', + label: 'Name', + required: true, + max_length: 50, + placeholder: 'Enter name', + tooltips: 'Your full name', + unit: '', + }), + ] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useConfigurations(variables)) + + expect(result.current).toHaveLength(1) + expect(result.current[0]).toMatchObject({ + type: BaseFieldType.textInput, + variable: 'name', + label: 'Name', + required: true, + maxLength: 50, + placeholder: 'Enter name', + tooltip: 'Your full name', + }) + }) + + it('should map select options correctly', () => { + const variables = [ + makeVariable({ + type: 'select', + variable: 'color', + options: ['red', 'green', 'blue'], + }), + ] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useConfigurations(variables)) + + expect(result.current[0].options).toEqual([ + { label: 'red', value: 'red' }, + { label: 'green', value: 'green' }, + { label: 'blue', value: 'blue' }, + ]) + }) + + it('should handle undefined options', () => { + const variables = [ + makeVariable({ type: 'text-input' }), + ] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useConfigurations(variables)) + + expect(result.current[0].options).toBeUndefined() + }) + + it('should include file-related fields for file type', () => { + const variables = [ + makeVariable({ + type: 'file', + variable: 'doc', + allowed_file_types: ['pdf', 'docx'], + allowed_file_extensions: ['.pdf', '.docx'], + allowed_file_upload_methods: ['local', 'remote'], + }), + ] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useConfigurations(variables)) + + expect(result.current[0].allowedFileTypes).toEqual(['pdf', 'docx']) + expect(result.current[0].allowedFileExtensions).toEqual(['.pdf', '.docx']) + expect(result.current[0].allowedFileUploadMethods).toEqual(['local', 'remote']) + }) + + it('should include showConditions as empty array', () => { + const variables = [ + makeVariable(), + ] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useConfigurations(variables)) + + expect(result.current[0].showConditions).toEqual([]) + }) + + it('should handle multiple variables', () => { + const variables = [ + makeVariable({ variable: 'a', type: 'text-input' }), + makeVariable({ variable: 'b', type: 'number' }), + makeVariable({ variable: 'c', type: 'checkbox' }), + ] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useConfigurations(variables)) + + expect(result.current).toHaveLength(3) + expect(result.current[0].variable).toBe('a') + expect(result.current[1].variable).toBe('b') + expect(result.current[2].variable).toBe('c') + }) + + it('should include unit field', () => { + const variables = [ + makeVariable({ type: 'number', unit: 'px' }), + ] as unknown as RAGPipelineVariables + const { result } = renderHook(() => useConfigurations(variables)) + + expect(result.current[0].unit).toBe('px') + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts similarity index 93% rename from web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts rename to web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts index 5788c860d1..82635a75b3 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -2,17 +2,8 @@ import { renderHook } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// ============================================================================ -// Import after mocks -// ============================================================================ +import { useNodesSyncDraft } from '../use-nodes-sync-draft' -import { useNodesSyncDraft } from './use-nodes-sync-draft' - -// ============================================================================ -// Mocks -// ============================================================================ - -// Mock reactflow const mockGetNodes = vi.fn() const mockStoreGetState = vi.fn() @@ -22,7 +13,6 @@ vi.mock('reactflow', () => ({ }), })) -// Mock workflow store const mockWorkflowStoreGetState = vi.fn() vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => ({ @@ -30,7 +20,6 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock useNodesReadOnly const mockGetNodesReadOnly = vi.fn() vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({ useNodesReadOnly: () => ({ @@ -38,7 +27,6 @@ vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({ }), })) -// Mock useSerialAsyncCallback - must pass through arguments vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({ useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise<void>, checkFn: () => boolean) => { return (...args: unknown[]) => { @@ -49,13 +37,11 @@ vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({ }, })) -// Mock service const mockSyncWorkflowDraft = vi.fn() vi.mock('@/service/workflow', () => ({ syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params), })) -// Mock usePipelineRefreshDraft const mockHandleRefreshWorkflowDraft = vi.fn() vi.mock('@/app/components/rag-pipeline/hooks', () => ({ usePipelineRefreshDraft: () => ({ @@ -63,26 +49,19 @@ vi.mock('@/app/components/rag-pipeline/hooks', () => ({ }), })) -// Mock API_PREFIX vi.mock('@/config', () => ({ API_PREFIX: '/api', })) -// Mock postWithKeepalive from service/fetch const mockPostWithKeepalive = vi.fn() vi.mock('@/service/fetch', () => ({ postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args), })) -// ============================================================================ -// Tests -// ============================================================================ - describe('useNodesSyncDraft', () => { beforeEach(() => { vi.clearAllMocks() - // Default store state mockStoreGetState.mockReturnValue({ getNodes: mockGetNodes, edges: [], @@ -204,7 +183,6 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - // Should not call postWithKeepalive because after filtering temp nodes, array is empty expect(mockPostWithKeepalive).not.toHaveBeenCalled() }) @@ -347,7 +325,6 @@ describe('useNodesSyncDraft', () => { await result.current.doSyncWorkflowDraft(false) }) - // Wait for json to be called await new Promise(resolve => setTimeout(resolve, 0)) expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled() @@ -371,7 +348,6 @@ describe('useNodesSyncDraft', () => { await result.current.doSyncWorkflowDraft(true) }) - // Wait for json to be called await new Promise(resolve => setTimeout(resolve, 0)) expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled() diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-config.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-config.spec.ts similarity index 92% rename from web/app/components/rag-pipeline/hooks/use-pipeline-config.spec.ts rename to web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-config.spec.ts index 491d2828d8..0b2c68bf68 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-config.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-config.spec.ts @@ -1,17 +1,8 @@ import { renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// ============================================================================ -// Import after mocks -// ============================================================================ +import { usePipelineConfig } from '../use-pipeline-config' -import { usePipelineConfig } from './use-pipeline-config' - -// ============================================================================ -// Mocks -// ============================================================================ - -// Mock workflow store const mockUseStore = vi.fn() const mockWorkflowStoreGetState = vi.fn() @@ -22,27 +13,20 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock useWorkflowConfig const mockUseWorkflowConfig = vi.fn() vi.mock('@/service/use-workflow', () => ({ useWorkflowConfig: (url: string, callback: (data: unknown) => void) => mockUseWorkflowConfig(url, callback), })) -// Mock useDataSourceList const mockUseDataSourceList = vi.fn() vi.mock('@/service/use-pipeline', () => ({ useDataSourceList: (enabled: boolean, callback: (data: unknown) => void) => mockUseDataSourceList(enabled, callback), })) -// Mock basePath vi.mock('@/utils/var', () => ({ basePath: '/base', })) -// ============================================================================ -// Tests -// ============================================================================ - describe('usePipelineConfig', () => { const mockSetNodesDefaultConfigs = vi.fn() const mockSetPublishedAt = vi.fn() @@ -239,7 +223,6 @@ describe('usePipelineConfig', () => { capturedCallback?.(dataSourceList) - // The callback modifies the array in place expect(dataSourceList[0].declaration.identity.icon).toBe('/base/icon.png') }) @@ -274,7 +257,6 @@ describe('usePipelineConfig', () => { capturedCallback?.(dataSourceList) - // Should not modify object icon expect(dataSourceList[0].declaration.identity.icon).toEqual({ url: '/icon.png' }) }) }) diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-init.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts similarity index 92% rename from web/app/components/rag-pipeline/hooks/use-pipeline-init.spec.ts rename to web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts index 3938525311..1ed50e820f 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-init.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts @@ -1,17 +1,8 @@ import { renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// ============================================================================ -// Import after mocks -// ============================================================================ +import { usePipelineInit } from '../use-pipeline-init' -import { usePipelineInit } from './use-pipeline-init' - -// ============================================================================ -// Mocks -// ============================================================================ - -// Mock workflow store const mockWorkflowStoreGetState = vi.fn() const mockWorkflowStoreSetState = vi.fn() vi.mock('@/app/components/workflow/store', () => ({ @@ -21,14 +12,12 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock dataset detail context const mockUseDatasetDetailContextWithSelector = vi.fn() vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: (selector: (state: Record<string, unknown>) => unknown) => mockUseDatasetDetailContextWithSelector(selector), })) -// Mock workflow service const mockFetchWorkflowDraft = vi.fn() const mockSyncWorkflowDraft = vi.fn() vi.mock('@/service/workflow', () => ({ @@ -36,23 +25,17 @@ vi.mock('@/service/workflow', () => ({ syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params), })) -// Mock usePipelineConfig -vi.mock('./use-pipeline-config', () => ({ +vi.mock('../use-pipeline-config', () => ({ usePipelineConfig: vi.fn(), })) -// Mock usePipelineTemplate -vi.mock('./use-pipeline-template', () => ({ +vi.mock('../use-pipeline-template', () => ({ usePipelineTemplate: () => ({ nodes: [{ id: 'template-node' }], edges: [], }), })) -// ============================================================================ -// Tests -// ============================================================================ - describe('usePipelineInit', () => { const mockSetEnvSecrets = vi.fn() const mockSetEnvironmentVariables = vi.fn() @@ -283,7 +266,6 @@ describe('usePipelineInit', () => { mockFetchWorkflowDraft.mockRejectedValueOnce(mockJsonError) mockSyncWorkflowDraft.mockResolvedValue({ updated_at: '2024-01-02T00:00:00Z' }) - // Second fetch succeeds mockFetchWorkflowDraft.mockResolvedValueOnce({ graph: { nodes: [], edges: [], viewport: {} }, hash: 'new-hash', diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts similarity index 90% rename from web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.spec.ts rename to web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts index efdb18b7d4..4ad8bc4582 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts @@ -2,17 +2,8 @@ import { renderHook, waitFor } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// ============================================================================ -// Import after mocks -// ============================================================================ +import { usePipelineRefreshDraft } from '../use-pipeline-refresh-draft' -import { usePipelineRefreshDraft } from './use-pipeline-refresh-draft' - -// ============================================================================ -// Mocks -// ============================================================================ - -// Mock workflow store const mockWorkflowStoreGetState = vi.fn() vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => ({ @@ -20,7 +11,6 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock useWorkflowUpdate const mockHandleUpdateWorkflowCanvas = vi.fn() vi.mock('@/app/components/workflow/hooks', () => ({ useWorkflowUpdate: () => ({ @@ -28,24 +18,18 @@ vi.mock('@/app/components/workflow/hooks', () => ({ }), })) -// Mock workflow service const mockFetchWorkflowDraft = vi.fn() vi.mock('@/service/workflow', () => ({ fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url), })) -// Mock utils -vi.mock('../utils', () => ({ +vi.mock('../../utils', () => ({ processNodesWithoutDataSource: (nodes: unknown[], viewport: unknown) => ({ nodes, viewport, }), })) -// ============================================================================ -// Tests -// ============================================================================ - describe('usePipelineRefreshDraft', () => { const mockSetSyncWorkflowDraftHash = vi.fn() const mockSetIsSyncingWorkflowDraft = vi.fn() diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-run.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts similarity index 95% rename from web/app/components/rag-pipeline/hooks/use-pipeline-run.spec.ts rename to web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts index c8a4a0ebb7..ed6013f1c2 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-run.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts @@ -1,20 +1,11 @@ -/* eslint-disable ts/no-explicit-any */ +import type { VersionHistory } from '@/types/workflow' import { renderHook } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WorkflowRunningStatus } from '@/app/components/workflow/types' -// ============================================================================ -// Import after mocks -// ============================================================================ +import { usePipelineRun } from '../use-pipeline-run' -import { usePipelineRun } from './use-pipeline-run' - -// ============================================================================ -// Mocks -// ============================================================================ - -// Mock reactflow const mockStoreGetState = vi.fn() const mockGetViewport = vi.fn() vi.mock('reactflow', () => ({ @@ -26,7 +17,6 @@ vi.mock('reactflow', () => ({ }), })) -// Mock workflow store const mockUseStore = vi.fn() const mockWorkflowStoreGetState = vi.fn() const mockWorkflowStoreSetState = vi.fn() @@ -38,15 +28,13 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock useNodesSyncDraft const mockDoSyncWorkflowDraft = vi.fn() -vi.mock('./use-nodes-sync-draft', () => ({ +vi.mock('../use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft, }), })) -// Mock workflow hooks vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: vi.fn(), @@ -80,7 +68,6 @@ vi.mock('@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run }), })) -// Mock service const mockSsePost = vi.fn() vi.mock('@/service/base', () => ({ ssePost: (url: string, ...args: unknown[]) => mockSsePost(url, ...args), @@ -98,17 +85,12 @@ vi.mock('@/service/use-workflow', () => ({ useInvalidateWorkflowRunHistory: () => mockInvalidateRunHistory, })) -// Mock FlowType vi.mock('@/types/common', () => ({ FlowType: { ragPipeline: 'rag-pipeline', }, })) -// ============================================================================ -// Tests -// ============================================================================ - describe('usePipelineRun', () => { const mockSetNodes = vi.fn() const mockGetNodes = vi.fn() @@ -120,7 +102,6 @@ describe('usePipelineRun', () => { beforeEach(() => { vi.clearAllMocks() - // Mock DOM element const mockWorkflowContainer = document.createElement('div') mockWorkflowContainer.id = 'workflow-container' Object.defineProperty(mockWorkflowContainer, 'clientWidth', { value: 1000 }) @@ -318,7 +299,7 @@ describe('usePipelineRun', () => { const { result } = renderHook(() => usePipelineRun()) act(() => { - result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any) + result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory) }) expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ @@ -342,7 +323,7 @@ describe('usePipelineRun', () => { const { result } = renderHook(() => usePipelineRun()) act(() => { - result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any) + result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory) }) expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ key: 'ENV', value: 'value' }]) @@ -362,7 +343,7 @@ describe('usePipelineRun', () => { const { result } = renderHook(() => usePipelineRun()) act(() => { - result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any) + result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory) }) expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ variable: 'query', type: 'text-input' }]) @@ -382,7 +363,7 @@ describe('usePipelineRun', () => { const { result } = renderHook(() => usePipelineRun()) act(() => { - result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any) + result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory) }) expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([]) @@ -468,7 +449,6 @@ describe('usePipelineRun', () => { await result.current.handleRun({ inputs: {} }, { onWorkflowStarted }) }) - // Trigger the callback await act(async () => { capturedCallbacks.onWorkflowStarted?.({ task_id: 'task-1' }) }) @@ -748,7 +728,6 @@ describe('usePipelineRun', () => { capturedCallbacks.onTextChunk?.({ text: 'chunk' }) }) - // Just verify it doesn't throw expect(capturedCallbacks.onTextChunk).toBeDefined() }) @@ -769,7 +748,6 @@ describe('usePipelineRun', () => { capturedCallbacks.onTextReplace?.({ text: 'replaced' }) }) - // Just verify it doesn't throw expect(capturedCallbacks.onTextReplace).toBeDefined() }) @@ -784,7 +762,7 @@ describe('usePipelineRun', () => { const { result } = renderHook(() => usePipelineRun()) await act(async () => { - await result.current.handleRun({ inputs: {} }, { onData: customCallback } as any) + await result.current.handleRun({ inputs: {} }, { onData: customCallback } as unknown as Parameters<typeof result.current.handleRun>[1]) }) expect(capturedCallbacks.onData).toBeDefined() @@ -799,12 +777,10 @@ describe('usePipelineRun', () => { const { result } = renderHook(() => usePipelineRun()) - // Run without any optional callbacks await act(async () => { await result.current.handleRun({ inputs: {} }) }) - // Trigger all callbacks - they should not throw even without optional handlers await act(async () => { capturedCallbacks.onWorkflowStarted?.({ task_id: 'task-1' }) capturedCallbacks.onWorkflowFinished?.({ status: 'succeeded' }) @@ -823,7 +799,6 @@ describe('usePipelineRun', () => { capturedCallbacks.onTextReplace?.({ text: 'replaced' }) }) - // Verify ssePost was called expect(mockSsePost).toHaveBeenCalled() }) }) diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts similarity index 90% rename from web/app/components/rag-pipeline/hooks/use-pipeline-start-run.spec.ts rename to web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts index 4266fb993d..11a1504c82 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts @@ -3,17 +3,8 @@ import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WorkflowRunningStatus } from '@/app/components/workflow/types' -// ============================================================================ -// Import after mocks -// ============================================================================ +import { usePipelineStartRun } from '../use-pipeline-start-run' -import { usePipelineStartRun } from './use-pipeline-start-run' - -// ============================================================================ -// Mocks -// ============================================================================ - -// Mock workflow store const mockWorkflowStoreGetState = vi.fn() const mockWorkflowStoreSetState = vi.fn() vi.mock('@/app/components/workflow/store', () => ({ @@ -23,7 +14,6 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -// Mock workflow interactions const mockHandleCancelDebugAndPreviewPanel = vi.fn() vi.mock('@/app/components/workflow/hooks', () => ({ useWorkflowInteractions: () => ({ @@ -31,7 +21,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({ }), })) -// Mock useNodesSyncDraft const mockDoSyncWorkflowDraft = vi.fn() vi.mock('@/app/components/rag-pipeline/hooks', () => ({ useNodesSyncDraft: () => ({ @@ -42,10 +31,6 @@ vi.mock('@/app/components/rag-pipeline/hooks', () => ({ }), })) -// ============================================================================ -// Tests -// ============================================================================ - describe('usePipelineStartRun', () => { const mockSetIsPreparingDataSource = vi.fn() const mockSetShowEnvPanel = vi.fn() @@ -210,7 +195,6 @@ describe('usePipelineStartRun', () => { result.current.handleStartWorkflowRun() }) - // Should trigger the same workflow as handleWorkflowStartRunInWorkflow expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false) }) }) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-template.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-template.spec.ts new file mode 100644 index 0000000000..1214ea84c0 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-template.spec.ts @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { usePipelineTemplate } from '../use-pipeline-template' + +vi.mock('@/app/components/workflow/constants', () => ({ + START_INITIAL_POSITION: { x: 100, y: 200 }, +})) + +vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({ + default: { + metaData: { type: 'knowledge-base' }, + defaultValue: { title: 'Knowledge Base' }, + }, +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + generateNewNode: ({ id, data, position }: { id: string, data: Record<string, unknown>, position: { x: number, y: number } }) => ({ + newNode: { id, data, position, type: 'custom' }, + }), +})) + +describe('usePipelineTemplate', () => { + it('should return nodes array with one knowledge base node', () => { + const { result } = renderHook(() => usePipelineTemplate()) + + expect(result.current.nodes).toHaveLength(1) + expect(result.current.nodes[0].id).toBe('knowledgeBase') + }) + + it('should return empty edges array', () => { + const { result } = renderHook(() => usePipelineTemplate()) + + expect(result.current.edges).toEqual([]) + }) + + it('should set node type from knowledge-base default', () => { + const { result } = renderHook(() => usePipelineTemplate()) + + expect(result.current.nodes[0].data.type).toBe('knowledge-base') + }) + + it('should set node as selected', () => { + const { result } = renderHook(() => usePipelineTemplate()) + + expect(result.current.nodes[0].data.selected).toBe(true) + }) + + it('should position node offset from START_INITIAL_POSITION', () => { + const { result } = renderHook(() => usePipelineTemplate()) + + expect(result.current.nodes[0].position.x).toBe(600) + expect(result.current.nodes[0].position.y).toBe(200) + }) + + it('should translate node title', () => { + const { result } = renderHook(() => usePipelineTemplate()) + + expect(result.current.nodes[0].data.title).toBe('workflow.blocks.knowledge-base') + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts new file mode 100644 index 0000000000..bca23fa602 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts @@ -0,0 +1,321 @@ +import { renderHook } from '@testing-library/react' +import { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { usePipeline } from '../use-pipeline' + +const mockGetNodes = vi.fn() +const mockSetNodes = vi.fn() +const mockEdges: Array<{ id: string, source: string, target: string }> = [] + +vi.mock('reactflow', () => ({ + useStoreApi: () => ({ + getState: () => ({ + getNodes: mockGetNodes, + setNodes: mockSetNodes, + edges: mockEdges, + }), + }), + getOutgoers: (node: { id: string }, nodes: Array<{ id: string }>, edges: Array<{ source: string, target: string }>) => { + return nodes.filter(n => edges.some(e => e.source === node.id && e.target === n.id)) + }, +})) + +const mockFindUsedVarNodes = vi.fn() +const mockUpdateNodeVars = vi.fn() +vi.mock('../../../workflow/nodes/_base/components/variable/utils', () => ({ + findUsedVarNodes: (...args: unknown[]) => mockFindUsedVarNodes(...args), + updateNodeVars: (...args: unknown[]) => mockUpdateNodeVars(...args), +})) + +vi.mock('../../../workflow/types', () => ({ + BlockEnum: { + DataSource: 'data-source', + }, +})) + +vi.mock('es-toolkit/compat', () => ({ + uniqBy: (arr: Array<{ id: string }>, key: string) => { + const seen = new Set<string>() + return arr.filter((item) => { + const val = item[key as keyof typeof item] as string + if (seen.has(val)) + return false + seen.add(val) + return true + }) + }, +})) + +function createNode(id: string, type: string) { + return { id, data: { type }, position: { x: 0, y: 0 } } +} + +describe('usePipeline', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEdges.length = 0 + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('hook initialization', () => { + it('should return handleInputVarRename function', () => { + mockGetNodes.mockReturnValue([]) + const { result } = renderHook(() => usePipeline()) + + expect(result.current.handleInputVarRename).toBeDefined() + expect(typeof result.current.handleInputVarRename).toBe('function') + }) + + it('should return isVarUsedInNodes function', () => { + mockGetNodes.mockReturnValue([]) + const { result } = renderHook(() => usePipeline()) + + expect(result.current.isVarUsedInNodes).toBeDefined() + expect(typeof result.current.isVarUsedInNodes).toBe('function') + }) + + it('should return removeUsedVarInNodes function', () => { + mockGetNodes.mockReturnValue([]) + const { result } = renderHook(() => usePipeline()) + + expect(result.current.removeUsedVarInNodes).toBeDefined() + expect(typeof result.current.removeUsedVarInNodes).toBe('function') + }) + }) + + describe('isVarUsedInNodes', () => { + it('should return true when variable is used in downstream nodes', () => { + const dsNode = createNode('ds-1', 'data-source') + const downstreamNode = createNode('node-2', 'llm') + mockGetNodes.mockReturnValue([dsNode, downstreamNode]) + mockEdges.push({ id: 'e1', source: 'ds-1', target: 'node-2' }) + mockFindUsedVarNodes.mockReturnValue([downstreamNode]) + + const { result } = renderHook(() => usePipeline()) + + const isUsed = result.current.isVarUsedInNodes(['rag', 'ds-1', 'var1']) + expect(isUsed).toBe(true) + expect(mockFindUsedVarNodes).toHaveBeenCalledWith( + ['rag', 'ds-1', 'var1'], + expect.any(Array), + ) + }) + + it('should return false when variable is not used', () => { + const dsNode = createNode('ds-1', 'data-source') + mockGetNodes.mockReturnValue([dsNode]) + mockFindUsedVarNodes.mockReturnValue([]) + + const { result } = renderHook(() => usePipeline()) + + const isUsed = result.current.isVarUsedInNodes(['rag', 'ds-1', 'var1']) + expect(isUsed).toBe(false) + }) + + it('should handle shared nodeId by collecting all datasource nodes', () => { + const ds1 = createNode('ds-1', 'data-source') + const ds2 = createNode('ds-2', 'data-source') + const node3 = createNode('node-3', 'llm') + mockGetNodes.mockReturnValue([ds1, ds2, node3]) + mockEdges.push({ id: 'e1', source: 'ds-1', target: 'node-3' }) + mockFindUsedVarNodes.mockReturnValue([node3]) + + const { result } = renderHook(() => usePipeline()) + + const isUsed = result.current.isVarUsedInNodes(['rag', 'shared', 'var1']) + expect(isUsed).toBe(true) + }) + + it('should return false for shared nodeId when no datasource nodes exist', () => { + mockGetNodes.mockReturnValue([createNode('node-1', 'llm')]) + mockFindUsedVarNodes.mockReturnValue([]) + + const { result } = renderHook(() => usePipeline()) + + const isUsed = result.current.isVarUsedInNodes(['rag', 'shared', 'var1']) + expect(isUsed).toBe(false) + }) + }) + + describe('handleInputVarRename', () => { + it('should rename variable in affected nodes', () => { + const dsNode = createNode('ds-1', 'data-source') + const node2 = createNode('node-2', 'llm') + const updatedNode2 = { ...node2, data: { ...node2.data, renamed: true } } + mockGetNodes.mockReturnValue([dsNode, node2]) + mockEdges.push({ id: 'e1', source: 'ds-1', target: 'node-2' }) + mockFindUsedVarNodes.mockReturnValue([node2]) + mockUpdateNodeVars.mockReturnValue(updatedNode2) + + const { result } = renderHook(() => usePipeline()) + + act(() => { + result.current.handleInputVarRename( + 'ds-1', + ['rag', 'ds-1', 'oldVar'], + ['rag', 'ds-1', 'newVar'], + ) + }) + + expect(mockFindUsedVarNodes).toHaveBeenCalledWith( + ['rag', 'ds-1', 'oldVar'], + expect.any(Array), + ) + expect(mockUpdateNodeVars).toHaveBeenCalledWith( + node2, + ['rag', 'ds-1', 'oldVar'], + ['rag', 'ds-1', 'newVar'], + ) + expect(mockSetNodes).toHaveBeenCalled() + }) + + it('should not call setNodes when no nodes are affected', () => { + const dsNode = createNode('ds-1', 'data-source') + mockGetNodes.mockReturnValue([dsNode]) + mockFindUsedVarNodes.mockReturnValue([]) + + const { result } = renderHook(() => usePipeline()) + + act(() => { + result.current.handleInputVarRename( + 'ds-1', + ['rag', 'ds-1', 'oldVar'], + ['rag', 'ds-1', 'newVar'], + ) + }) + + expect(mockSetNodes).not.toHaveBeenCalled() + }) + + it('should only update affected nodes, leave others unchanged', () => { + const dsNode = createNode('ds-1', 'data-source') + const node2 = createNode('node-2', 'llm') + const node3 = createNode('node-3', 'end') + mockGetNodes.mockReturnValue([dsNode, node2, node3]) + mockEdges.push( + { id: 'e1', source: 'ds-1', target: 'node-2' }, + { id: 'e2', source: 'node-2', target: 'node-3' }, + ) + mockFindUsedVarNodes.mockReturnValue([node2]) + const updatedNode2 = { ...node2, updated: true } + mockUpdateNodeVars.mockReturnValue(updatedNode2) + + const { result } = renderHook(() => usePipeline()) + + act(() => { + result.current.handleInputVarRename( + 'ds-1', + ['rag', 'ds-1', 'var1'], + ['rag', 'ds-1', 'var2'], + ) + }) + + const setNodesArg = mockSetNodes.mock.calls[0][0] + expect(setNodesArg).toContain(dsNode) + expect(setNodesArg).toContain(updatedNode2) + expect(setNodesArg).toContain(node3) + }) + }) + + describe('removeUsedVarInNodes', () => { + it('should remove variable references from affected nodes', () => { + const dsNode = createNode('ds-1', 'data-source') + const node2 = createNode('node-2', 'llm') + const cleanedNode2 = { ...node2, data: { ...node2.data, cleaned: true } } + mockGetNodes.mockReturnValue([dsNode, node2]) + mockEdges.push({ id: 'e1', source: 'ds-1', target: 'node-2' }) + mockFindUsedVarNodes.mockReturnValue([node2]) + mockUpdateNodeVars.mockReturnValue(cleanedNode2) + + const { result } = renderHook(() => usePipeline()) + + act(() => { + result.current.removeUsedVarInNodes(['rag', 'ds-1', 'var1']) + }) + + expect(mockUpdateNodeVars).toHaveBeenCalledWith( + node2, + ['rag', 'ds-1', 'var1'], + [], // Empty array removes the variable + ) + expect(mockSetNodes).toHaveBeenCalled() + }) + + it('should not call setNodes when no nodes use the variable', () => { + const dsNode = createNode('ds-1', 'data-source') + mockGetNodes.mockReturnValue([dsNode]) + mockFindUsedVarNodes.mockReturnValue([]) + + const { result } = renderHook(() => usePipeline()) + + act(() => { + result.current.removeUsedVarInNodes(['rag', 'ds-1', 'var1']) + }) + + expect(mockSetNodes).not.toHaveBeenCalled() + }) + }) + + describe('getAllNodesInSameBranch — edge cases', () => { + it('should traverse multi-level downstream nodes', () => { + const ds = createNode('ds-1', 'data-source') + const n2 = createNode('node-2', 'llm') + const n3 = createNode('node-3', 'end') + mockGetNodes.mockReturnValue([ds, n2, n3]) + mockEdges.push( + { id: 'e1', source: 'ds-1', target: 'node-2' }, + { id: 'e2', source: 'node-2', target: 'node-3' }, + ) + mockFindUsedVarNodes.mockReturnValue([n3]) + mockUpdateNodeVars.mockReturnValue(n3) + + const { result } = renderHook(() => usePipeline()) + + const isUsed = result.current.isVarUsedInNodes(['rag', 'ds-1', 'var1']) + expect(isUsed).toBe(true) + + const nodesArg = mockFindUsedVarNodes.mock.calls[0][1] as Array<{ id: string }> + const nodeIds = nodesArg.map(n => n.id) + expect(nodeIds).toContain('ds-1') + expect(nodeIds).toContain('node-2') + expect(nodeIds).toContain('node-3') + }) + + it('should return empty array for non-existent node', () => { + mockGetNodes.mockReturnValue([createNode('ds-1', 'data-source')]) + mockFindUsedVarNodes.mockReturnValue([]) + + const { result } = renderHook(() => usePipeline()) + + const isUsed = result.current.isVarUsedInNodes(['rag', 'non-existent', 'var1']) + expect(isUsed).toBe(false) + }) + + it('should deduplicate nodes when traversal finds shared nodes', () => { + const ds = createNode('ds-1', 'data-source') + const n2 = createNode('node-2', 'llm') + const n3 = createNode('node-3', 'llm') + const n4 = createNode('node-4', 'end') + mockGetNodes.mockReturnValue([ds, n2, n3, n4]) + mockEdges.push( + { id: 'e1', source: 'ds-1', target: 'node-2' }, + { id: 'e2', source: 'ds-1', target: 'node-3' }, + { id: 'e3', source: 'node-2', target: 'node-4' }, + { id: 'e4', source: 'node-3', target: 'node-4' }, + ) + mockFindUsedVarNodes.mockReturnValue([]) + + const { result } = renderHook(() => usePipeline()) + + result.current.isVarUsedInNodes(['rag', 'ds-1', 'var1']) + + const nodesArg = mockFindUsedVarNodes.mock.calls[0][1] as Array<{ id: string }> + const nodeIds = nodesArg.map(n => n.id) + const uniqueIds = [...new Set(nodeIds)] + expect(nodeIds.length).toBe(uniqueIds.length) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-rag-pipeline-search.spec.tsx b/web/app/components/rag-pipeline/hooks/__tests__/use-rag-pipeline-search.spec.tsx new file mode 100644 index 0000000000..a06d1ba334 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-rag-pipeline-search.spec.tsx @@ -0,0 +1,221 @@ +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { BlockEnum } from '@/app/components/workflow/types' +import { useRagPipelineSearch } from '../use-rag-pipeline-search' + +const mockNodes: Array<{ id: string, data: Record<string, unknown> }> = [] +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + default: () => mockNodes, +})) + +const mockHandleNodeSelect = vi.fn() +vi.mock('@/app/components/workflow/hooks/use-nodes-interactions', () => ({ + useNodesInteractions: () => ({ + handleNodeSelect: mockHandleNodeSelect, + }), +})) + +vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({ + useGetToolIcon: () => () => null, +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () => null, +})) + +type MockSearchResult = { + title: string + type: string + description?: string + metadata?: { nodeId: string } +} + +const mockRagPipelineNodesAction = vi.hoisted(() => { + return { searchFn: undefined as undefined | ((query: string) => MockSearchResult[]) } +}) +vi.mock('@/app/components/goto-anything/actions/rag-pipeline-nodes', () => ({ + ragPipelineNodesAction: mockRagPipelineNodesAction, +})) + +const mockCleanupListener = vi.fn() +vi.mock('@/app/components/workflow/utils/node-navigation', () => ({ + setupNodeSelectionListener: () => mockCleanupListener, +})) + +describe('useRagPipelineSearch', () => { + beforeEach(() => { + vi.clearAllMocks() + mockNodes.length = 0 + mockRagPipelineNodesAction.searchFn = undefined + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('hook lifecycle', () => { + it('should return null', () => { + const { result } = renderHook(() => useRagPipelineSearch()) + expect(result.current).toBeNull() + }) + + it('should register search function when nodes exist', () => { + mockNodes.push({ + id: 'node-1', + data: { type: BlockEnum.LLM, title: 'LLM Node', desc: '' }, + }) + + renderHook(() => useRagPipelineSearch()) + + expect(mockRagPipelineNodesAction.searchFn).toBeDefined() + }) + + it('should not register search function when no nodes', () => { + renderHook(() => useRagPipelineSearch()) + + expect(mockRagPipelineNodesAction.searchFn).toBeUndefined() + }) + + it('should cleanup search function on unmount', () => { + mockNodes.push({ + id: 'node-1', + data: { type: BlockEnum.Start, title: 'Start', desc: '' }, + }) + + const { unmount } = renderHook(() => useRagPipelineSearch()) + + expect(mockRagPipelineNodesAction.searchFn).toBeDefined() + + unmount() + + expect(mockRagPipelineNodesAction.searchFn).toBeUndefined() + }) + + it('should setup node selection listener', () => { + const { unmount } = renderHook(() => useRagPipelineSearch()) + + unmount() + + expect(mockCleanupListener).toHaveBeenCalled() + }) + }) + + describe('search functionality', () => { + beforeEach(() => { + mockNodes.push( + { + id: 'node-1', + data: { type: BlockEnum.LLM, title: 'GPT Model', desc: 'Language model' }, + }, + { + id: 'node-2', + data: { type: BlockEnum.KnowledgeRetrieval, title: 'Knowledge Base', desc: 'Search knowledge', dataset_ids: ['ds1', 'ds2'] }, + }, + { + id: 'node-3', + data: { type: BlockEnum.Tool, title: 'Web Search', desc: '', tool_description: 'Search the web', tool_label: 'WebSearch' }, + }, + { + id: 'node-4', + data: { type: BlockEnum.Start, title: 'Start Node', desc: 'Pipeline entry' }, + }, + ) + }) + + it('should find nodes by title', () => { + renderHook(() => useRagPipelineSearch()) + + const searchFn = mockRagPipelineNodesAction.searchFn! + const results = searchFn('GPT') + + expect(results.length).toBeGreaterThan(0) + expect(results[0].title).toBe('GPT Model') + }) + + it('should find nodes by type', () => { + renderHook(() => useRagPipelineSearch()) + + const searchFn = mockRagPipelineNodesAction.searchFn! + const results = searchFn(BlockEnum.LLM) + + expect(results.some(r => r.title === 'GPT Model')).toBe(true) + }) + + it('should find nodes by description', () => { + renderHook(() => useRagPipelineSearch()) + + const searchFn = mockRagPipelineNodesAction.searchFn! + const results = searchFn('knowledge') + + expect(results.some(r => r.title === 'Knowledge Base')).toBe(true) + }) + + it('should return all nodes when search term is empty', () => { + renderHook(() => useRagPipelineSearch()) + + const searchFn = mockRagPipelineNodesAction.searchFn! + const results = searchFn('') + + expect(results.length).toBe(4) + }) + + it('should sort by alphabetical order when no search term', () => { + renderHook(() => useRagPipelineSearch()) + + const searchFn = mockRagPipelineNodesAction.searchFn! + const results = searchFn('') + const titles = results.map(r => r.title) + + const sortedTitles = [...titles].sort((a, b) => a.localeCompare(b)) + expect(titles).toEqual(sortedTitles) + }) + + it('should sort by relevance score when search term provided', () => { + renderHook(() => useRagPipelineSearch()) + + const searchFn = mockRagPipelineNodesAction.searchFn! + const results = searchFn('Search') + + expect(results[0].title).toBe('Web Search') + }) + + it('should return empty array when no nodes match', () => { + renderHook(() => useRagPipelineSearch()) + + const searchFn = mockRagPipelineNodesAction.searchFn! + const results = searchFn('nonexistent-xyz-12345') + + expect(results).toEqual([]) + }) + + it('should enhance Tool node description from tool_description', () => { + renderHook(() => useRagPipelineSearch()) + + const searchFn = mockRagPipelineNodesAction.searchFn! + const results = searchFn('web') + + const toolResult = results.find(r => r.title === 'Web Search') + expect(toolResult).toBeDefined() + expect(toolResult?.description).toContain('Search the web') + }) + + it('should include metadata with nodeId', () => { + renderHook(() => useRagPipelineSearch()) + + const searchFn = mockRagPipelineNodesAction.searchFn! + const results = searchFn('Start') + + const startResult = results.find(r => r.title === 'Start Node') + expect(startResult?.metadata?.nodeId).toBe('node-4') + }) + + it('should set result type as workflow-node', () => { + renderHook(() => useRagPipelineSearch()) + + const searchFn = mockRagPipelineNodesAction.searchFn! + const results = searchFn('Start') + + expect(results[0].type).toBe('workflow-node') + }) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts similarity index 94% rename from web/app/components/rag-pipeline/hooks/use-update-dsl-modal.spec.ts rename to web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts index adf756c10f..942e337ad8 100644 --- a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts @@ -1,9 +1,8 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DSLImportMode, DSLImportStatus } from '@/models/app' -import { useUpdateDSLModal } from './use-update-dsl-modal' +import { useUpdateDSLModal } from '../use-update-dsl-modal' -// --- FileReader stub --- class MockFileReader { onload: ((this: FileReader, event: ProgressEvent<FileReader>) => void) | null = null @@ -14,18 +13,12 @@ class MockFileReader { } vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) -// --- Module-level mock functions --- const mockNotify = vi.fn() const mockEmit = vi.fn() const mockImportDSL = vi.fn() const mockImportDSLConfirm = vi.fn() const mockHandleCheckPluginDependencies = vi.fn() -// --- Mocks --- -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})) - vi.mock('use-context-selector', () => ({ useContext: () => ({ notify: mockNotify }), })) @@ -74,10 +67,8 @@ vi.mock('@/service/workflow', () => ({ }), })) -// --- Helpers --- const createFile = () => new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) -// Cast MouseEventHandler to a plain callable for tests (event param is unused) type AsyncFn = () => Promise<void> describe('useUpdateDSLModal', () => { @@ -102,7 +93,6 @@ describe('useUpdateDSLModal', () => { mockHandleCheckPluginDependencies.mockResolvedValue(undefined) }) - // Initial state values describe('initial state', () => { it('should return correct defaults', () => { const { result } = renderUpdateDSLModal() @@ -115,7 +105,6 @@ describe('useUpdateDSLModal', () => { }) }) - // File handling describe('handleFile', () => { it('should set currentFile when file is provided', () => { const { result } = renderUpdateDSLModal() @@ -142,7 +131,6 @@ describe('useUpdateDSLModal', () => { }) }) - // Modal state management describe('modal state', () => { it('should allow toggling showErrorModal', () => { const { result } = renderUpdateDSLModal() @@ -161,7 +149,6 @@ describe('useUpdateDSLModal', () => { }) }) - // Import flow describe('handleImport', () => { it('should call importDSL with correct parameters', async () => { const { result } = renderUpdateDSLModal() @@ -191,7 +178,6 @@ describe('useUpdateDSLModal', () => { expect(mockImportDSL).not.toHaveBeenCalled() }) - // COMPLETED status it('should notify success on COMPLETED status', async () => { const { result } = renderUpdateDSLModal() act(() => { @@ -257,7 +243,6 @@ describe('useUpdateDSLModal', () => { expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('test-pipeline-id', true) }) - // COMPLETED_WITH_WARNINGS status it('should notify warning on COMPLETED_WITH_WARNINGS status', async () => { mockImportDSL.mockResolvedValue({ id: 'import-id', @@ -277,7 +262,6 @@ describe('useUpdateDSLModal', () => { expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' })) }) - // PENDING status (version mismatch) it('should switch to version mismatch modal on PENDING status', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) @@ -338,7 +322,6 @@ describe('useUpdateDSLModal', () => { vi.useRealTimers() }) - // FAILED / unknown status it('should notify error on FAILED status', async () => { mockImportDSL.mockResolvedValue({ id: 'import-id', @@ -358,7 +341,6 @@ describe('useUpdateDSLModal', () => { expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) - // Exception it('should notify error when importDSL throws', async () => { mockImportDSL.mockRejectedValue(new Error('Network error')) @@ -374,7 +356,6 @@ describe('useUpdateDSLModal', () => { expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) - // Missing pipeline_id it('should notify error when pipeline_id is missing on success', async () => { mockImportDSL.mockResolvedValue({ id: 'import-id', @@ -395,9 +376,7 @@ describe('useUpdateDSLModal', () => { }) }) - // Confirm flow (after PENDING → version mismatch) describe('onUpdateDSLConfirm', () => { - // Helper: drive the hook into PENDING state so importId is set const setupPendingState = async (result: { current: ReturnType<typeof useUpdateDSLModal> }) => { vi.useFakeTimers({ shouldAdvanceTime: true }) @@ -520,7 +499,6 @@ describe('useUpdateDSLModal', () => { it('should not call importDSLConfirm when importId is not set', async () => { const { result } = renderUpdateDSLModal() - // No pending state → importId is undefined await act(async () => { await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() }) @@ -529,7 +507,6 @@ describe('useUpdateDSLModal', () => { }) }) - // Optional onImport callback describe('optional onImport', () => { it('should work without onImport callback', async () => { const { result } = renderHook(() => @@ -544,7 +521,6 @@ describe('useUpdateDSLModal', () => { await (result.current.handleImport as unknown as AsyncFn)() }) - // Should succeed without throwing expect(mockOnCancel).toHaveBeenCalled() }) }) diff --git a/web/app/components/rag-pipeline/store/index.spec.ts b/web/app/components/rag-pipeline/store/__tests__/index.spec.ts similarity index 65% rename from web/app/components/rag-pipeline/store/index.spec.ts rename to web/app/components/rag-pipeline/store/__tests__/index.spec.ts index c8c0a35330..c978332a71 100644 --- a/web/app/components/rag-pipeline/store/index.spec.ts +++ b/web/app/components/rag-pipeline/store/__tests__/index.spec.ts @@ -1,9 +1,12 @@ -/* eslint-disable ts/no-explicit-any */ +import type { InputFieldEditorProps } from '../../components/panel/input-field/editor' +import type { RagPipelineSliceShape } from '../index' import type { DataSourceItem } from '@/app/components/workflow/block-selector/types' +import type { RAGPipelineVariables } from '@/models/pipeline' import { describe, expect, it, vi } from 'vitest' -import { createRagPipelineSliceSlice } from './index' +import { PipelineInputVarType } from '@/models/pipeline' + +import { createRagPipelineSliceSlice } from '../index' -// Mock the transformDataSourceToTool function vi.mock('@/app/components/workflow/block-selector/utils', () => ({ transformDataSourceToTool: (item: DataSourceItem) => ({ ...item, @@ -11,60 +14,68 @@ vi.mock('@/app/components/workflow/block-selector/utils', () => ({ }), })) +type SliceCreatorParams = Parameters<typeof createRagPipelineSliceSlice> +const unusedGet = vi.fn() as unknown as SliceCreatorParams[1] +const unusedApi = vi.fn() as unknown as SliceCreatorParams[2] + +function createSlice(mockSet = vi.fn()) { + return createRagPipelineSliceSlice(mockSet as unknown as SliceCreatorParams[0], unusedGet, unusedApi) +} + describe('createRagPipelineSliceSlice', () => { const mockSet = vi.fn() describe('initial state', () => { it('should have empty pipelineId', () => { - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) expect(slice.pipelineId).toBe('') }) it('should have empty knowledgeName', () => { - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) expect(slice.knowledgeName).toBe('') }) it('should have showInputFieldPanel as false', () => { - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) expect(slice.showInputFieldPanel).toBe(false) }) it('should have showInputFieldPreviewPanel as false', () => { - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) expect(slice.showInputFieldPreviewPanel).toBe(false) }) it('should have inputFieldEditPanelProps as null', () => { - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) expect(slice.inputFieldEditPanelProps).toBeNull() }) it('should have empty nodesDefaultConfigs', () => { - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) expect(slice.nodesDefaultConfigs).toEqual({}) }) it('should have empty ragPipelineVariables', () => { - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) expect(slice.ragPipelineVariables).toEqual([]) }) it('should have empty dataSourceList', () => { - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) expect(slice.dataSourceList).toEqual([]) }) it('should have isPreparingDataSource as false', () => { - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) expect(slice.isPreparingDataSource).toBe(false) }) @@ -72,25 +83,24 @@ describe('createRagPipelineSliceSlice', () => { describe('setShowInputFieldPanel', () => { it('should call set with showInputFieldPanel true', () => { - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) slice.setShowInputFieldPanel(true) expect(mockSet).toHaveBeenCalledWith(expect.any(Function)) - // Get the setter function and execute it - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ showInputFieldPanel: true }) }) it('should call set with showInputFieldPanel false', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) slice.setShowInputFieldPanel(false) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ showInputFieldPanel: false }) }) @@ -99,22 +109,22 @@ describe('createRagPipelineSliceSlice', () => { describe('setShowInputFieldPreviewPanel', () => { it('should call set with showInputFieldPreviewPanel true', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) slice.setShowInputFieldPreviewPanel(true) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ showInputFieldPreviewPanel: true }) }) it('should call set with showInputFieldPreviewPanel false', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) slice.setShowInputFieldPreviewPanel(false) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ showInputFieldPreviewPanel: false }) }) @@ -123,23 +133,23 @@ describe('createRagPipelineSliceSlice', () => { describe('setInputFieldEditPanelProps', () => { it('should call set with inputFieldEditPanelProps object', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) - const props = { type: 'create' as const } + const slice = createSlice(mockSet) + const props = { onClose: vi.fn(), onSubmit: vi.fn() } as unknown as InputFieldEditorProps - slice.setInputFieldEditPanelProps(props as any) + slice.setInputFieldEditPanelProps(props) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ inputFieldEditPanelProps: props }) }) it('should call set with inputFieldEditPanelProps null', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) slice.setInputFieldEditPanelProps(null) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ inputFieldEditPanelProps: null }) }) @@ -148,23 +158,23 @@ describe('createRagPipelineSliceSlice', () => { describe('setNodesDefaultConfigs', () => { it('should call set with nodesDefaultConfigs', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) - const configs = { node1: { key: 'value' } } + const slice = createSlice(mockSet) + const configs: Record<string, unknown> = { node1: { key: 'value' } } slice.setNodesDefaultConfigs(configs) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ nodesDefaultConfigs: configs }) }) it('should call set with empty nodesDefaultConfigs', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) slice.setNodesDefaultConfigs({}) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ nodesDefaultConfigs: {} }) }) @@ -173,25 +183,25 @@ describe('createRagPipelineSliceSlice', () => { describe('setRagPipelineVariables', () => { it('should call set with ragPipelineVariables', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) - const variables = [ - { type: 'text-input', variable: 'var1', label: 'Var 1', required: true }, + const slice = createSlice(mockSet) + const variables: RAGPipelineVariables = [ + { type: PipelineInputVarType.textInput, variable: 'var1', label: 'Var 1', required: true, belong_to_node_id: 'node-1' }, ] - slice.setRagPipelineVariables(variables as any) + slice.setRagPipelineVariables(variables) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ ragPipelineVariables: variables }) }) it('should call set with empty ragPipelineVariables', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) slice.setRagPipelineVariables([]) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ ragPipelineVariables: [] }) }) @@ -200,7 +210,7 @@ describe('createRagPipelineSliceSlice', () => { describe('setDataSourceList', () => { it('should transform and set dataSourceList', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) const dataSourceList: DataSourceItem[] = [ { name: 'source1', key: 'key1' } as unknown as DataSourceItem, { name: 'source2', key: 'key2' } as unknown as DataSourceItem, @@ -208,20 +218,20 @@ describe('createRagPipelineSliceSlice', () => { slice.setDataSourceList(dataSourceList) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result.dataSourceList).toHaveLength(2) - expect(result.dataSourceList[0]).toEqual({ name: 'source1', key: 'key1', transformed: true }) - expect(result.dataSourceList[1]).toEqual({ name: 'source2', key: 'key2', transformed: true }) + expect(result.dataSourceList![0]).toEqual({ name: 'source1', key: 'key1', transformed: true }) + expect(result.dataSourceList![1]).toEqual({ name: 'source2', key: 'key2', transformed: true }) }) it('should set empty dataSourceList', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) slice.setDataSourceList([]) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result.dataSourceList).toEqual([]) }) @@ -230,22 +240,22 @@ describe('createRagPipelineSliceSlice', () => { describe('setIsPreparingDataSource', () => { it('should call set with isPreparingDataSource true', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) slice.setIsPreparingDataSource(true) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ isPreparingDataSource: true }) }) it('should call set with isPreparingDataSource false', () => { mockSet.mockClear() - const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any) + const slice = createSlice(mockSet) slice.setIsPreparingDataSource(false) - const setterFn = mockSet.mock.calls[0][0] + const setterFn = mockSet.mock.calls[0][0] as () => Partial<RagPipelineSliceShape> const result = setterFn() expect(result).toEqual({ isPreparingDataSource: false }) }) @@ -254,9 +264,8 @@ describe('createRagPipelineSliceSlice', () => { describe('RagPipelineSliceShape type', () => { it('should define all required properties', () => { - const slice = createRagPipelineSliceSlice(vi.fn(), vi.fn() as any, vi.fn() as any) + const slice = createSlice() - // Check all properties exist expect(slice).toHaveProperty('pipelineId') expect(slice).toHaveProperty('knowledgeName') expect(slice).toHaveProperty('showInputFieldPanel') @@ -276,7 +285,7 @@ describe('RagPipelineSliceShape type', () => { }) it('should have all setters as functions', () => { - const slice = createRagPipelineSliceSlice(vi.fn(), vi.fn() as any, vi.fn() as any) + const slice = createSlice() expect(typeof slice.setShowInputFieldPanel).toBe('function') expect(typeof slice.setShowInputFieldPreviewPanel).toBe('function') diff --git a/web/app/components/rag-pipeline/utils/index.spec.ts b/web/app/components/rag-pipeline/utils/__tests__/index.spec.ts similarity index 93% rename from web/app/components/rag-pipeline/utils/index.spec.ts rename to web/app/components/rag-pipeline/utils/__tests__/index.spec.ts index 9d816af685..787cc018b9 100644 --- a/web/app/components/rag-pipeline/utils/index.spec.ts +++ b/web/app/components/rag-pipeline/utils/__tests__/index.spec.ts @@ -2,9 +2,8 @@ import type { Viewport } from 'reactflow' import type { Node } from '@/app/components/workflow/types' import { describe, expect, it, vi } from 'vitest' import { BlockEnum } from '@/app/components/workflow/types' -import { processNodesWithoutDataSource } from './nodes' +import { processNodesWithoutDataSource } from '../nodes' -// Mock constants vi.mock('@/app/components/workflow/constants', () => ({ CUSTOM_NODE: 'custom', NODE_WIDTH_X_OFFSET: 400, @@ -121,8 +120,6 @@ describe('processNodesWithoutDataSource', () => { const result = processNodesWithoutDataSource(nodes, viewport) - // New nodes should be positioned based on the leftmost node (x: 200) - // startX = 200 - 400 = -200 expect(result.nodes[0].position.x).toBe(-200) expect(result.nodes[0].position.y).toBe(100) }) @@ -140,10 +137,6 @@ describe('processNodesWithoutDataSource', () => { const result = processNodesWithoutDataSource(nodes, viewport) - // startX = 300 - 400 = -100 - // startY = 200 - // viewport.x = (100 - (-100)) * 1 = 200 - // viewport.y = (100 - 200) * 1 = -100 expect(result.viewport).toEqual({ x: 200, y: -100, @@ -164,10 +157,6 @@ describe('processNodesWithoutDataSource', () => { const result = processNodesWithoutDataSource(nodes, viewport) - // startX = 300 - 400 = -100 - // startY = 200 - // viewport.x = (100 - (-100)) * 2 = 400 - // viewport.y = (100 - 200) * 2 = -200 expect(result.viewport).toEqual({ x: 400, y: -200, @@ -202,7 +191,6 @@ describe('processNodesWithoutDataSource', () => { const result = processNodesWithoutDataSource(nodes) - // Data source empty node position const dataSourceEmptyNode = result.nodes[0] const noteNode = result.nodes[1] @@ -276,7 +264,6 @@ describe('processNodesWithoutDataSource', () => { const result = processNodesWithoutDataSource(nodes, viewport) - // No custom nodes to find leftmost, so no new nodes are added expect(result.nodes).toBe(nodes) expect(result.viewport).toBe(viewport) }) @@ -301,7 +288,6 @@ describe('processNodesWithoutDataSource', () => { const result = processNodesWithoutDataSource(nodes) - // First node should be used as leftNode expect(result.nodes.length).toBe(4) }) @@ -317,7 +303,6 @@ describe('processNodesWithoutDataSource', () => { const result = processNodesWithoutDataSource(nodes) - // startX = -100 - 400 = -500 expect(result.nodes[0].position.x).toBe(-500) expect(result.nodes[0].position.y).toBe(-50) }) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index eff3e27589..90571e4947 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -5359,11 +5359,6 @@ "count": 3 } }, - "app/components/rag-pipeline/components/panel/input-field/field-list/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/rag-pipeline/components/panel/input-field/hooks.ts": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -5523,11 +5518,6 @@ "count": 1 } }, - "app/components/rag-pipeline/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 8 - } - }, "app/components/rag-pipeline/store/index.ts": { "ts/no-explicit-any": { "count": 2 From 3fd1eea4d7c8d58a7a77f1cb2fab60df9b167a38 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 12 Feb 2026 10:29:03 +0800 Subject: [PATCH 08/18] feat(tests): add integration tests for explore app list, installed apps, and sidebar lifecycle flows (#32248) Co-authored-by: CodingOnStar <hanxujiang@dify.com> --- .../explore/explore-app-list-flow.test.tsx | 273 ++++++++++++++++++ .../explore/installed-app-flow.test.tsx | 260 +++++++++++++++++ .../explore/sidebar-lifecycle-flow.test.tsx | 225 +++++++++++++++ .../explore/{ => __tests__}/category.spec.tsx | 17 +- .../explore/{ => __tests__}/index.spec.tsx | 13 +- .../explore/app-card/__tests__/index.spec.tsx | 140 +++++++++ .../explore/app-card/index.spec.tsx | 87 ------ .../app-list/{ => __tests__}/index.spec.tsx | 24 +- .../{ => __tests__}/banner-item.spec.tsx | 30 +- .../banner/{ => __tests__}/banner.spec.tsx | 18 +- .../{ => __tests__}/indicator-button.spec.tsx | 18 +- .../{ => __tests__}/index.spec.tsx | 210 ++++++-------- .../{ => __tests__}/index.spec.tsx | 11 +- .../{ => __tests__}/index.spec.tsx | 24 +- .../sidebar/{ => __tests__}/index.spec.tsx | 105 +++++-- .../{ => __tests__}/index.spec.tsx | 18 +- .../sidebar/no-apps/__tests__/index.spec.tsx | 63 ++++ .../try-app/{ => __tests__}/index.spec.tsx | 44 +-- .../try-app/{ => __tests__}/tab.spec.tsx | 26 +- .../app-info/{ => __tests__}/index.spec.tsx | 45 +-- .../use-get-requirements.spec.ts | 3 +- .../try-app/app/{ => __tests__}/chat.spec.tsx | 27 +- .../app/{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/text-generation.spec.tsx | 24 +- .../basic-app-preview.spec.tsx | 11 +- .../{ => __tests__}/flow-app-preview.spec.tsx | 2 +- .../preview/{ => __tests__}/index.spec.tsx | 6 +- 27 files changed, 1186 insertions(+), 550 deletions(-) create mode 100644 web/__tests__/explore/explore-app-list-flow.test.tsx create mode 100644 web/__tests__/explore/installed-app-flow.test.tsx create mode 100644 web/__tests__/explore/sidebar-lifecycle-flow.test.tsx rename web/app/components/explore/{ => __tests__}/category.spec.tsx (84%) rename web/app/components/explore/{ => __tests__}/index.spec.tsx (91%) create mode 100644 web/app/components/explore/app-card/__tests__/index.spec.tsx delete mode 100644 web/app/components/explore/app-card/index.spec.tsx rename web/app/components/explore/app-list/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/explore/banner/{ => __tests__}/banner-item.spec.tsx (91%) rename web/app/components/explore/banner/{ => __tests__}/banner.spec.tsx (94%) rename web/app/components/explore/banner/{ => __tests__}/indicator-button.spec.tsx (92%) rename web/app/components/explore/create-app-modal/{ => __tests__}/index.spec.tsx (74%) rename web/app/components/explore/installed-app/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/explore/item-operation/{ => __tests__}/index.spec.tsx (83%) rename web/app/components/explore/sidebar/{ => __tests__}/index.spec.tsx (62%) rename web/app/components/explore/sidebar/app-nav-item/{ => __tests__}/index.spec.tsx (83%) create mode 100644 web/app/components/explore/sidebar/no-apps/__tests__/index.spec.tsx rename web/app/components/explore/try-app/{ => __tests__}/index.spec.tsx (89%) rename web/app/components/explore/try-app/{ => __tests__}/tab.spec.tsx (65%) rename web/app/components/explore/try-app/app-info/{ => __tests__}/index.spec.tsx (86%) rename web/app/components/explore/try-app/app-info/{ => __tests__}/use-get-requirements.spec.ts (99%) rename web/app/components/explore/try-app/app/{ => __tests__}/chat.spec.tsx (89%) rename web/app/components/explore/try-app/app/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/explore/try-app/app/{ => __tests__}/text-generation.spec.tsx (92%) rename web/app/components/explore/try-app/preview/{ => __tests__}/basic-app-preview.spec.tsx (98%) rename web/app/components/explore/try-app/preview/{ => __tests__}/flow-app-preview.spec.tsx (99%) rename web/app/components/explore/try-app/preview/{ => __tests__}/index.spec.tsx (97%) diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx new file mode 100644 index 0000000000..1a54135420 --- /dev/null +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -0,0 +1,273 @@ +/** + * Integration test: Explore App List Flow + * + * Tests the end-to-end user flow of browsing, filtering, searching, + * and adding apps to workspace from the explore page. + */ +import type { Mock } from 'vitest' +import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' +import type { App } from '@/models/explore' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import AppList from '@/app/components/explore/app-list' +import ExploreContext from '@/context/explore-context' +import { fetchAppDetail } from '@/service/explore' +import { AppModeEnum } from '@/types/app' + +const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}' +let mockTabValue = allCategoriesEn +const mockSetTab = vi.fn() +let mockExploreData: { categories: string[], allList: App[] } | undefined +let mockIsLoading = false +const mockHandleImportDSL = vi.fn() +const mockHandleImportDSLConfirm = vi.fn() + +vi.mock('nuqs', async (importOriginal) => { + const actual = await importOriginal<typeof import('nuqs')>() + return { + ...actual, + useQueryState: () => [mockTabValue, mockSetTab], + } +}) + +vi.mock('ahooks', async () => { + const actual = await vi.importActual<typeof import('ahooks')>('ahooks') + const React = await vi.importActual<typeof import('react')>('react') + return { + ...actual, + useDebounceFn: (fn: (...args: unknown[]) => void) => { + const fnRef = React.useRef(fn) + fnRef.current = fn + return { + run: () => setTimeout(() => fnRef.current(), 0), + } + }, + } +}) + +vi.mock('@/service/use-explore', () => ({ + useExploreAppList: () => ({ + data: mockExploreData, + isLoading: mockIsLoading, + isError: false, + }), +})) + +vi.mock('@/service/explore', () => ({ + fetchAppDetail: vi.fn(), + fetchAppList: vi.fn(), +})) + +vi.mock('@/hooks/use-import-dsl', () => ({ + useImportDSL: () => ({ + handleImportDSL: mockHandleImportDSL, + handleImportDSLConfirm: mockHandleImportDSLConfirm, + versions: ['v1'], + isFetching: false, + }), +})) + +vi.mock('@/app/components/explore/create-app-modal', () => ({ + default: (props: CreateAppModalProps) => { + if (!props.show) + return null + return ( + <div data-testid="create-app-modal"> + <button + data-testid="confirm-create" + onClick={() => props.onConfirm({ + name: 'New App', + icon_type: 'emoji', + icon: 'đŸ€–', + icon_background: '#fff', + description: 'desc', + })} + > + confirm + </button> + <button data-testid="hide-create" onClick={props.onHide}>hide</button> + </div> + ) + }, +})) + +vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({ + default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( + <div data-testid="dsl-confirm-modal"> + <button data-testid="dsl-confirm" onClick={onConfirm}>confirm</button> + <button data-testid="dsl-cancel" onClick={onCancel}>cancel</button> + </div> + ), +})) + +const createApp = (overrides: Partial<App> = {}): App => ({ + app: { + id: overrides.app?.id ?? 'app-id', + mode: overrides.app?.mode ?? AppModeEnum.CHAT, + icon_type: overrides.app?.icon_type ?? 'emoji', + icon: overrides.app?.icon ?? '😀', + icon_background: overrides.app?.icon_background ?? '#fff', + icon_url: overrides.app?.icon_url ?? '', + name: overrides.app?.name ?? 'Alpha', + description: overrides.app?.description ?? 'Alpha description', + use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, + }, + can_trial: true, + app_id: overrides.app_id ?? 'app-1', + description: overrides.description ?? 'Alpha description', + copyright: overrides.copyright ?? '', + privacy_policy: overrides.privacy_policy ?? null, + custom_disclaimer: overrides.custom_disclaimer ?? null, + category: overrides.category ?? 'Writing', + position: overrides.position ?? 1, + is_listed: overrides.is_listed ?? true, + install_count: overrides.install_count ?? 0, + installed: overrides.installed ?? false, + editable: overrides.editable ?? false, + is_agent: overrides.is_agent ?? false, +}) + +const createContextValue = (hasEditPermission = true) => ({ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission, + installedApps: [] as never[], + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: false, + setShowTryAppPanel: vi.fn(), +}) + +const wrapWithContext = (hasEditPermission = true, onSuccess?: () => void) => ( + <ExploreContext.Provider value={createContextValue(hasEditPermission)}> + <AppList onSuccess={onSuccess} /> + </ExploreContext.Provider> +) + +const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => { + return render(wrapWithContext(hasEditPermission, onSuccess)) +} + +describe('Explore App List Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTabValue = allCategoriesEn + mockIsLoading = false + mockExploreData = { + categories: ['Writing', 'Translate', 'Programming'], + allList: [ + createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }), + createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, category: 'Translate' }), + createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, category: 'Programming' }), + ], + } + }) + + describe('Browse and Filter Flow', () => { + it('should display all apps when no category filter is applied', () => { + renderWithContext() + + expect(screen.getByText('Writer Bot')).toBeInTheDocument() + expect(screen.getByText('Translator')).toBeInTheDocument() + expect(screen.getByText('Code Helper')).toBeInTheDocument() + }) + + it('should filter apps by selected category', () => { + mockTabValue = 'Writing' + renderWithContext() + + expect(screen.getByText('Writer Bot')).toBeInTheDocument() + expect(screen.queryByText('Translator')).not.toBeInTheDocument() + expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() + }) + + it('should filter apps by search keyword', async () => { + renderWithContext() + + const input = screen.getByPlaceholderText('common.operation.search') + fireEvent.change(input, { target: { value: 'trans' } }) + + await waitFor(() => { + expect(screen.getByText('Translator')).toBeInTheDocument() + expect(screen.queryByText('Writer Bot')).not.toBeInTheDocument() + expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() + }) + }) + }) + + describe('Add to Workspace Flow', () => { + it('should complete the full add-to-workspace flow with DSL confirmation', async () => { + // Step 1: User clicks "Add to Workspace" on an app card + const onSuccess = vi.fn() + ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' }) + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => { + options.onPending?.() + }) + mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + + renderWithContext(true, onSuccess) + + // Step 2: Click add to workspace button - opens create modal + fireEvent.click(screen.getAllByText('explore.appCard.addToWorkspace')[0]) + + // Step 3: Confirm creation in modal + fireEvent.click(await screen.findByTestId('confirm-create')) + + // Step 4: API fetches app detail + await waitFor(() => { + expect(fetchAppDetail).toHaveBeenCalledWith('app-id') + }) + + // Step 5: DSL import triggers pending confirmation + expect(mockHandleImportDSL).toHaveBeenCalledTimes(1) + + // Step 6: DSL confirm modal appears and user confirms + expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('dsl-confirm')) + + // Step 7: Flow completes successfully + await waitFor(() => { + expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('Loading and Empty States', () => { + it('should transition from loading to content', () => { + // Step 1: Loading state + mockIsLoading = true + mockExploreData = undefined + const { rerender } = render(wrapWithContext()) + + expect(screen.getByRole('status')).toBeInTheDocument() + + // Step 2: Data loads + mockIsLoading = false + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + rerender(wrapWithContext()) + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.getByText('Alpha')).toBeInTheDocument() + }) + }) + + describe('Permission-Based Behavior', () => { + it('should hide add-to-workspace button when user has no edit permission', () => { + renderWithContext(false) + + expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() + }) + + it('should show add-to-workspace button when user has edit permission', () => { + renderWithContext(true) + + expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0) + }) + }) +}) diff --git a/web/__tests__/explore/installed-app-flow.test.tsx b/web/__tests__/explore/installed-app-flow.test.tsx new file mode 100644 index 0000000000..69dcb116aa --- /dev/null +++ b/web/__tests__/explore/installed-app-flow.test.tsx @@ -0,0 +1,260 @@ +/** + * Integration test: Installed App Flow + * + * Tests the end-to-end user flow of installed apps: sidebar navigation, + * mode-based routing (Chat / Completion / Workflow), and lifecycle + * operations (pin/unpin, delete). + */ +import type { Mock } from 'vitest' +import type { InstalledApp as InstalledAppModel } from '@/models/explore' +import { render, screen, waitFor } from '@testing-library/react' +import { useContext } from 'use-context-selector' +import InstalledApp from '@/app/components/explore/installed-app' +import { useWebAppStore } from '@/context/web-app-context' +import { AccessMode } from '@/models/access-control' +import { useGetUserCanAccessApp } from '@/service/access-control' +import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore' +import { AppModeEnum } from '@/types/app' + +// Mock external dependencies +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(), + createContext: vi.fn(() => ({})), +})) + +vi.mock('@/context/web-app-context', () => ({ + useWebAppStore: vi.fn(), +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: vi.fn(), +})) + +vi.mock('@/service/use-explore', () => ({ + useGetInstalledAppAccessModeByAppId: vi.fn(), + useGetInstalledAppParams: vi.fn(), + useGetInstalledAppMeta: vi.fn(), +})) + +vi.mock('@/app/components/share/text-generation', () => ({ + default: ({ isWorkflow }: { isWorkflow?: boolean }) => ( + <div data-testid="text-generation-app"> + Text Generation + {isWorkflow && ' (Workflow)'} + </div> + ), +})) + +vi.mock('@/app/components/base/chat/chat-with-history', () => ({ + default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => ( + <div data-testid="chat-with-history"> + Chat - + {' '} + {installedAppInfo?.app.name} + </div> + ), +})) + +describe('Installed App Flow', () => { + const mockUpdateAppInfo = vi.fn() + const mockUpdateWebAppAccessMode = vi.fn() + const mockUpdateAppParams = vi.fn() + const mockUpdateWebAppMeta = vi.fn() + const mockUpdateUserCanAccessApp = vi.fn() + + const createInstalledApp = (mode: AppModeEnum = AppModeEnum.CHAT): InstalledAppModel => ({ + id: 'installed-app-1', + app: { + id: 'real-app-id', + name: 'Integration Test App', + mode, + icon_type: 'emoji', + icon: 'đŸ§Ș', + icon_background: '#FFFFFF', + icon_url: '', + description: 'Test app for integration', + use_icon_as_answer_icon: false, + }, + uninstallable: true, + is_pinned: false, + }) + + const mockAppParams = { + user_input_form: [], + file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } }, + system_parameters: {}, + } + + type MockOverrides = { + context?: { installedApps?: InstalledAppModel[], isFetchingInstalledApps?: boolean } + accessMode?: { isFetching?: boolean, data?: unknown, error?: unknown } + params?: { isFetching?: boolean, data?: unknown, error?: unknown } + meta?: { isFetching?: boolean, data?: unknown, error?: unknown } + userAccess?: { data?: unknown, error?: unknown } + } + + const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => { + ;(useContext as Mock).mockReturnValue({ + installedApps: app ? [app] : [], + isFetchingInstalledApps: false, + ...overrides.context, + }) + + ;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, Mock>) => unknown) => { + return selector({ + updateAppInfo: mockUpdateAppInfo, + updateWebAppAccessMode: mockUpdateWebAppAccessMode, + updateAppParams: mockUpdateAppParams, + updateWebAppMeta: mockUpdateWebAppMeta, + updateUserCanAccessApp: mockUpdateUserCanAccessApp, + }) + }) + + ;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({ + isFetching: false, + data: { accessMode: AccessMode.PUBLIC }, + error: null, + ...overrides.accessMode, + }) + + ;(useGetInstalledAppParams as Mock).mockReturnValue({ + isFetching: false, + data: mockAppParams, + error: null, + ...overrides.params, + }) + + ;(useGetInstalledAppMeta as Mock).mockReturnValue({ + isFetching: false, + data: { tool_icons: {} }, + error: null, + ...overrides.meta, + }) + + ;(useGetUserCanAccessApp as Mock).mockReturnValue({ + data: { result: true }, + error: null, + ...overrides.userAccess, + }) + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Mode-Based Routing', () => { + it.each([ + [AppModeEnum.CHAT, 'chat-with-history'], + [AppModeEnum.ADVANCED_CHAT, 'chat-with-history'], + [AppModeEnum.AGENT_CHAT, 'chat-with-history'], + ])('should render ChatWithHistory for %s mode', (mode, testId) => { + const app = createInstalledApp(mode) + setupDefaultMocks(app) + + render(<InstalledApp id="installed-app-1" />) + + expect(screen.getByTestId(testId)).toBeInTheDocument() + expect(screen.getByText(/Integration Test App/)).toBeInTheDocument() + }) + + it('should render TextGenerationApp for COMPLETION mode', () => { + const app = createInstalledApp(AppModeEnum.COMPLETION) + setupDefaultMocks(app) + + render(<InstalledApp id="installed-app-1" />) + + expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() + expect(screen.getByText('Text Generation')).toBeInTheDocument() + expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument() + }) + + it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => { + const app = createInstalledApp(AppModeEnum.WORKFLOW) + setupDefaultMocks(app) + + render(<InstalledApp id="installed-app-1" />) + + expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() + expect(screen.getByText(/Workflow/)).toBeInTheDocument() + }) + }) + + describe('Data Loading Flow', () => { + it('should show loading spinner when params are being fetched', () => { + const app = createInstalledApp() + setupDefaultMocks(app, { params: { isFetching: true, data: null } }) + + const { container } = render(<InstalledApp id="installed-app-1" />) + + expect(container.querySelector('svg.spin-animation')).toBeInTheDocument() + expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument() + }) + + it('should render content when all data is available', () => { + const app = createInstalledApp() + setupDefaultMocks(app) + + render(<InstalledApp id="installed-app-1" />) + + expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() + }) + }) + + describe('Error Handling Flow', () => { + it('should show error state when API fails', () => { + const app = createInstalledApp() + setupDefaultMocks(app, { params: { data: null, error: new Error('Network error') } }) + + render(<InstalledApp id="installed-app-1" />) + + expect(screen.getByText(/Network error/)).toBeInTheDocument() + }) + + it('should show 404 when app is not found', () => { + setupDefaultMocks(undefined, { + accessMode: { data: null }, + params: { data: null }, + meta: { data: null }, + userAccess: { data: null }, + }) + + render(<InstalledApp id="nonexistent" />) + + expect(screen.getByText(/404/)).toBeInTheDocument() + }) + + it('should show 403 when user has no permission', () => { + const app = createInstalledApp() + setupDefaultMocks(app, { userAccess: { data: { result: false } } }) + + render(<InstalledApp id="installed-app-1" />) + + expect(screen.getByText(/403/)).toBeInTheDocument() + }) + }) + + describe('State Synchronization', () => { + it('should update all stores when app data is loaded', async () => { + const app = createInstalledApp() + setupDefaultMocks(app) + + render(<InstalledApp id="installed-app-1" />) + + await waitFor(() => { + expect(mockUpdateAppInfo).toHaveBeenCalledWith( + expect.objectContaining({ + app_id: 'installed-app-1', + site: expect.objectContaining({ + title: 'Integration Test App', + icon: 'đŸ§Ș', + }), + }), + ) + expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams) + expect(mockUpdateWebAppMeta).toHaveBeenCalledWith({ tool_icons: {} }) + expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC) + expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true) + }) + }) + }) +}) diff --git a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx new file mode 100644 index 0000000000..bf4821ced4 --- /dev/null +++ b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx @@ -0,0 +1,225 @@ +import type { IExplore } from '@/context/explore-context' +/** + * Integration test: Sidebar Lifecycle Flow + * + * Tests the sidebar interactions for installed apps lifecycle: + * navigation, pin/unpin ordering, delete confirmation, and + * fold/unfold behavior. + */ +import type { InstalledApp } from '@/models/explore' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Toast from '@/app/components/base/toast' +import SideBar from '@/app/components/explore/sidebar' +import ExploreContext from '@/context/explore-context' +import { MediaType } from '@/hooks/use-breakpoints' +import { AppModeEnum } from '@/types/app' + +let mockMediaType: string = MediaType.pc +const mockSegments = ['apps'] +const mockPush = vi.fn() +const mockRefetch = vi.fn() +const mockUninstall = vi.fn() +const mockUpdatePinStatus = vi.fn() +let mockInstalledApps: InstalledApp[] = [] + +vi.mock('next/navigation', () => ({ + useSelectedLayoutSegments: () => mockSegments, + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => mockMediaType, + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, +})) + +vi.mock('@/service/use-explore', () => ({ + useGetInstalledApps: () => ({ + isFetching: false, + data: { installed_apps: mockInstalledApps }, + refetch: mockRefetch, + }), + useUninstallApp: () => ({ + mutateAsync: mockUninstall, + }), + useUpdateAppPinStatus: () => ({ + mutateAsync: mockUpdatePinStatus, + }), +})) + +const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({ + id: overrides.id ?? 'app-1', + uninstallable: overrides.uninstallable ?? false, + is_pinned: overrides.is_pinned ?? false, + app: { + id: overrides.app?.id ?? 'app-basic-id', + mode: overrides.app?.mode ?? AppModeEnum.CHAT, + icon_type: overrides.app?.icon_type ?? 'emoji', + icon: overrides.app?.icon ?? 'đŸ€–', + icon_background: overrides.app?.icon_background ?? '#fff', + icon_url: overrides.app?.icon_url ?? '', + name: overrides.app?.name ?? 'App One', + description: overrides.app?.description ?? 'desc', + use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, + }, +}) + +const createContextValue = (installedApps: InstalledApp[] = []): IExplore => ({ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission: true, + installedApps, + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: false, + setShowTryAppPanel: vi.fn(), +}) + +const renderSidebar = (installedApps: InstalledApp[] = []) => { + return render( + <ExploreContext.Provider value={createContextValue(installedApps)}> + <SideBar controlUpdateInstalledApps={0} /> + </ExploreContext.Provider>, + ) +} + +describe('Sidebar Lifecycle Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMediaType = MediaType.pc + mockInstalledApps = [] + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + }) + + describe('Pin / Unpin / Delete Flow', () => { + it('should complete pin → unpin cycle for an app', async () => { + mockUpdatePinStatus.mockResolvedValue(undefined) + + // Step 1: Start with an unpinned app and pin it + const unpinnedApp = createInstalledApp({ is_pinned: false }) + mockInstalledApps = [unpinnedApp] + const { unmount } = renderSidebar(mockInstalledApps) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) + + await waitFor(() => { + expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true }) + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + + // Step 2: Simulate refetch returning pinned state, then unpin + unmount() + vi.clearAllMocks() + mockUpdatePinStatus.mockResolvedValue(undefined) + + const pinnedApp = createInstalledApp({ is_pinned: true }) + mockInstalledApps = [pinnedApp] + renderSidebar(mockInstalledApps) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.unpin')) + + await waitFor(() => { + expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false }) + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + }) + + it('should complete the delete flow with confirmation', async () => { + const app = createInstalledApp() + mockInstalledApps = [app] + mockUninstall.mockResolvedValue(undefined) + + renderSidebar(mockInstalledApps) + + // Step 1: Open operation menu and click delete + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + // Step 2: Confirm dialog appears + expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument() + + // Step 3: Confirm deletion + fireEvent.click(screen.getByText('common.operation.confirm')) + + // Step 4: Uninstall API called and success toast shown + await waitFor(() => { + expect(mockUninstall).toHaveBeenCalledWith('app-1') + expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + message: 'common.api.remove', + })) + }) + }) + + it('should cancel deletion when user clicks cancel', async () => { + const app = createInstalledApp() + mockInstalledApps = [app] + + renderSidebar(mockInstalledApps) + + // Open delete flow + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + // Cancel the deletion + fireEvent.click(await screen.findByText('common.operation.cancel')) + + // Uninstall should not be called + expect(mockUninstall).not.toHaveBeenCalled() + }) + }) + + describe('Multi-App Ordering', () => { + it('should display pinned apps before unpinned apps with divider', () => { + mockInstalledApps = [ + createInstalledApp({ id: 'pinned-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }), + createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }), + ] + + const { container } = renderSidebar(mockInstalledApps) + + // Both apps are rendered + const pinnedApp = screen.getByText('Pinned App') + const regularApp = screen.getByText('Regular App') + expect(pinnedApp).toBeInTheDocument() + expect(regularApp).toBeInTheDocument() + + // Pinned app appears before unpinned app in the DOM + const pinnedItem = pinnedApp.closest('[class*="rounded-lg"]')! + const regularItem = regularApp.closest('[class*="rounded-lg"]')! + expect(pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + // Divider is rendered between pinned and unpinned sections + const divider = container.querySelector('[class*="bg-divider-regular"]') + expect(divider).toBeInTheDocument() + }) + }) + + describe('Empty State', () => { + it('should show NoApps component when no apps are installed on desktop', () => { + mockMediaType = MediaType.pc + renderSidebar([]) + + expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument() + }) + + it('should hide NoApps on mobile', () => { + mockMediaType = MediaType.mobile + renderSidebar([]) + + expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/explore/category.spec.tsx b/web/app/components/explore/__tests__/category.spec.tsx similarity index 84% rename from web/app/components/explore/category.spec.tsx rename to web/app/components/explore/__tests__/category.spec.tsx index a84b17c844..33349204d0 100644 --- a/web/app/components/explore/category.spec.tsx +++ b/web/app/components/explore/__tests__/category.spec.tsx @@ -1,6 +1,6 @@ import type { AppCategory } from '@/models/explore' import { fireEvent, render, screen } from '@testing-library/react' -import Category from './category' +import Category from '../category' describe('Category', () => { const allCategoriesEn = 'Recommended' @@ -19,59 +19,44 @@ describe('Category', () => { } } - // Rendering: basic categories and all-categories button. describe('Rendering', () => { it('should render all categories item and translated categories', () => { - // Arrange renderComponent() - // Assert expect(screen.getByText('explore.apps.allCategories')).toBeInTheDocument() expect(screen.getByText('explore.category.Writing')).toBeInTheDocument() }) it('should not render allCategoriesEn again inside the category list', () => { - // Arrange renderComponent() - // Assert const recommendedItems = screen.getAllByText('explore.apps.allCategories') expect(recommendedItems).toHaveLength(1) }) }) - // Props: clicking items triggers onChange. describe('Props', () => { it('should call onChange with category value when category item is clicked', () => { - // Arrange const { props } = renderComponent() - // Act fireEvent.click(screen.getByText('explore.category.Writing')) - // Assert expect(props.onChange).toHaveBeenCalledWith('Writing') }) it('should call onChange with allCategoriesEn when all categories is clicked', () => { - // Arrange const { props } = renderComponent({ value: 'Writing' }) - // Act fireEvent.click(screen.getByText('explore.apps.allCategories')) - // Assert expect(props.onChange).toHaveBeenCalledWith(allCategoriesEn) }) }) - // Edge cases: handle values not in the list. describe('Edge Cases', () => { it('should treat unknown value as all categories selection', () => { - // Arrange renderComponent({ value: 'Unknown' }) - // Assert const allCategoriesItem = screen.getByText('explore.apps.allCategories') expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active') }) diff --git a/web/app/components/explore/index.spec.tsx b/web/app/components/explore/__tests__/index.spec.tsx similarity index 91% rename from web/app/components/explore/index.spec.tsx rename to web/app/components/explore/__tests__/index.spec.tsx index e64c0c365a..b7ba9eccd2 100644 --- a/web/app/components/explore/index.spec.tsx +++ b/web/app/components/explore/__tests__/index.spec.tsx @@ -6,7 +6,7 @@ import ExploreContext from '@/context/explore-context' import { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { useMembers } from '@/service/use-common' -import Explore from './index' +import Explore from '../index' const mockReplace = vi.fn() const mockPush = vi.fn() @@ -65,10 +65,8 @@ describe('Explore', () => { vi.clearAllMocks() }) - // Rendering: provides ExploreContext and children. describe('Rendering', () => { it('should render children and provide edit permission from members role', async () => { - // Arrange ; (useAppContext as Mock).mockReturnValue({ userProfile: { id: 'user-1' }, isCurrentWorkspaceDatasetOperator: false, @@ -79,57 +77,48 @@ describe('Explore', () => { }, }) - // Act render(( <Explore> <ContextReader /> </Explore> )) - // Assert await waitFor(() => { expect(screen.getByText('edit-yes')).toBeInTheDocument() }) }) }) - // Effects: set document title and redirect dataset operators. describe('Effects', () => { it('should set document title on render', () => { - // Arrange ; (useAppContext as Mock).mockReturnValue({ userProfile: { id: 'user-1' }, isCurrentWorkspaceDatasetOperator: false, }); (useMembers as Mock).mockReturnValue({ data: { accounts: [] } }) - // Act render(( <Explore> <div>child</div> </Explore> )) - // Assert expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore') }) it('should redirect dataset operators to /datasets', async () => { - // Arrange ; (useAppContext as Mock).mockReturnValue({ userProfile: { id: 'user-1' }, isCurrentWorkspaceDatasetOperator: true, }); (useMembers as Mock).mockReturnValue({ data: { accounts: [] } }) - // Act render(( <Explore> <div>child</div> </Explore> )) - // Assert await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('/datasets') }) diff --git a/web/app/components/explore/app-card/__tests__/index.spec.tsx b/web/app/components/explore/app-card/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f5bb5e9615 --- /dev/null +++ b/web/app/components/explore/app-card/__tests__/index.spec.tsx @@ -0,0 +1,140 @@ +import type { AppCardProps } from '../index' +import type { App } from '@/models/explore' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { AppModeEnum } from '@/types/app' +import AppCard from '../index' + +vi.mock('../../../app/type-selector', () => ({ + AppTypeIcon: ({ type }: { type: string }) => <div data-testid="app-type-icon">{type}</div>, +})) + +const createApp = (overrides?: Partial<App>): App => ({ + can_trial: true, + app_id: 'app-id', + description: 'App description', + copyright: '2024', + privacy_policy: null, + custom_disclaimer: null, + category: 'Assistant', + position: 1, + is_listed: true, + install_count: 0, + installed: false, + editable: true, + is_agent: false, + ...overrides, + app: { + id: 'id-1', + mode: AppModeEnum.CHAT, + icon_type: null, + icon: 'đŸ€–', + icon_background: '#fff', + icon_url: '', + name: 'Sample App', + description: 'App description', + use_icon_as_answer_icon: false, + ...overrides?.app, + }, +}) + +describe('AppCard', () => { + const onCreate = vi.fn() + + const renderComponent = (props?: Partial<AppCardProps>) => { + const mergedProps: AppCardProps = { + app: createApp(), + canCreate: false, + onCreate, + isExplore: false, + ...props, + } + return render(<AppCard {...mergedProps} />) + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render app name and description', () => { + renderComponent() + + expect(screen.getByText('Sample App')).toBeInTheDocument() + expect(screen.getByText('App description')).toBeInTheDocument() + }) + + it.each([ + [AppModeEnum.CHAT, 'APP.TYPES.CHATBOT'], + [AppModeEnum.ADVANCED_CHAT, 'APP.TYPES.ADVANCED'], + [AppModeEnum.AGENT_CHAT, 'APP.TYPES.AGENT'], + [AppModeEnum.WORKFLOW, 'APP.TYPES.WORKFLOW'], + [AppModeEnum.COMPLETION, 'APP.TYPES.COMPLETION'], + ])('should render correct mode label for %s mode', (mode, label) => { + renderComponent({ app: createApp({ app: { ...createApp().app, mode } }) }) + + expect(screen.getByText(label)).toBeInTheDocument() + expect(screen.getByTestId('app-type-icon')).toHaveTextContent(mode) + }) + + it('should render description in a truncatable container', () => { + renderComponent({ app: createApp({ description: 'Very long description text' }) }) + + const descWrapper = screen.getByText('Very long description text') + expect(descWrapper).toHaveClass('line-clamp-4') + }) + }) + + describe('User Interactions', () => { + it('should show create button in explore mode and trigger action', () => { + renderComponent({ + app: createApp({ app: { ...createApp().app, mode: AppModeEnum.WORKFLOW } }), + canCreate: true, + isExplore: true, + }) + + const button = screen.getByText('explore.appCard.addToWorkspace') + expect(button).toBeInTheDocument() + fireEvent.click(button) + expect(onCreate).toHaveBeenCalledTimes(1) + }) + + it('should render try button in explore mode', () => { + renderComponent({ canCreate: true, isExplore: true }) + + expect(screen.getByText('explore.appCard.try')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should hide action buttons when not in explore mode', () => { + renderComponent({ canCreate: true, isExplore: false }) + + expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() + expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument() + }) + + it('should hide create button when canCreate is false', () => { + renderComponent({ canCreate: false, isExplore: true }) + + expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should truncate long app name with title attribute', () => { + const longName = 'A Very Long Application Name That Should Be Truncated' + renderComponent({ app: createApp({ app: { ...createApp().app, name: longName } }) }) + + const nameElement = screen.getByText(longName) + expect(nameElement).toHaveAttribute('title', longName) + expect(nameElement).toHaveClass('truncate') + }) + + it('should render with empty description', () => { + renderComponent({ app: createApp({ description: '' }) }) + + expect(screen.getByText('Sample App')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/explore/app-card/index.spec.tsx b/web/app/components/explore/app-card/index.spec.tsx deleted file mode 100644 index 152eab92a9..0000000000 --- a/web/app/components/explore/app-card/index.spec.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { AppCardProps } from './index' -import type { App } from '@/models/explore' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import { AppModeEnum } from '@/types/app' -import AppCard from './index' - -vi.mock('../../app/type-selector', () => ({ - AppTypeIcon: ({ type }: any) => <div data-testid="app-type-icon">{type}</div>, -})) - -const createApp = (overrides?: Partial<App>): App => ({ - can_trial: true, - app_id: 'app-id', - description: 'App description', - copyright: '2024', - privacy_policy: null, - custom_disclaimer: null, - category: 'Assistant', - position: 1, - is_listed: true, - install_count: 0, - installed: false, - editable: true, - is_agent: false, - ...overrides, - app: { - id: 'id-1', - mode: AppModeEnum.CHAT, - icon_type: null, - icon: 'đŸ€–', - icon_background: '#fff', - icon_url: '', - name: 'Sample App', - description: 'App description', - use_icon_as_answer_icon: false, - ...overrides?.app, - }, -}) - -describe('AppCard', () => { - const onCreate = vi.fn() - - const renderComponent = (props?: Partial<AppCardProps>) => { - const mergedProps: AppCardProps = { - app: createApp(), - canCreate: false, - onCreate, - isExplore: false, - ...props, - } - return render(<AppCard {...mergedProps} />) - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render app info with correct mode label when mode is CHAT', () => { - renderComponent({ app: createApp({ app: { ...createApp().app, mode: AppModeEnum.CHAT } }) }) - - expect(screen.getByText('Sample App')).toBeInTheDocument() - expect(screen.getByText('App description')).toBeInTheDocument() - expect(screen.getByText('APP.TYPES.CHATBOT')).toBeInTheDocument() - expect(screen.getByTestId('app-type-icon')).toHaveTextContent(AppModeEnum.CHAT) - }) - - it('should show create button in explore mode and trigger action', () => { - renderComponent({ - app: createApp({ app: { ...createApp().app, mode: AppModeEnum.WORKFLOW } }), - canCreate: true, - isExplore: true, - }) - - const button = screen.getByText('explore.appCard.addToWorkspace') - expect(button).toBeInTheDocument() - fireEvent.click(button) - expect(onCreate).toHaveBeenCalledTimes(1) - expect(screen.getByText('APP.TYPES.WORKFLOW')).toBeInTheDocument() - }) - - it('should hide create button when not allowed', () => { - renderComponent({ canCreate: false, isExplore: true }) - - expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() - }) -}) diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx similarity index 94% rename from web/app/components/explore/app-list/index.spec.tsx rename to web/app/components/explore/app-list/__tests__/index.spec.tsx index a87d5a2363..cb83fd3147 100644 --- a/web/app/components/explore/app-list/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import ExploreContext from '@/context/explore-context' import { fetchAppDetail } from '@/service/explore' import { AppModeEnum } from '@/types/app' -import AppList from './index' +import AppList from '../index' const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}' let mockTabValue = allCategoriesEn @@ -150,70 +150,55 @@ describe('AppList', () => { mockIsError = false }) - // Rendering: show loading when categories are not ready. describe('Rendering', () => { it('should render loading when the query is loading', () => { - // Arrange mockExploreData = undefined mockIsLoading = true - // Act renderWithContext() - // Assert expect(screen.getByRole('status')).toBeInTheDocument() }) it('should render app cards when data is available', () => { - // Arrange mockExploreData = { categories: ['Writing', 'Translate'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], } - // Act renderWithContext() - // Assert expect(screen.getByText('Alpha')).toBeInTheDocument() expect(screen.getByText('Beta')).toBeInTheDocument() }) }) - // Props: category selection filters the list. describe('Props', () => { it('should filter apps by selected category', () => { - // Arrange mockTabValue = 'Writing' mockExploreData = { categories: ['Writing', 'Translate'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], } - // Act renderWithContext() - // Assert expect(screen.getByText('Alpha')).toBeInTheDocument() expect(screen.queryByText('Beta')).not.toBeInTheDocument() }) }) - // User interactions: search and create flow. describe('User Interactions', () => { it('should filter apps by search keywords', async () => { - // Arrange mockExploreData = { categories: ['Writing'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], } renderWithContext() - // Act const input = screen.getByPlaceholderText('common.operation.search') fireEvent.change(input, { target: { value: 'gam' } }) - // Assert await waitFor(() => { expect(screen.queryByText('Alpha')).not.toBeInTheDocument() expect(screen.getByText('Gamma')).toBeInTheDocument() @@ -221,7 +206,6 @@ describe('AppList', () => { }) it('should handle create flow and confirm DSL when pending', async () => { - // Arrange const onSuccess = vi.fn() mockExploreData = { categories: ['Writing'], @@ -235,12 +219,10 @@ describe('AppList', () => { options.onSuccess?.() }) - // Act renderWithContext(true, onSuccess) fireEvent.click(screen.getByText('explore.appCard.addToWorkspace')) fireEvent.click(await screen.findByTestId('confirm-create')) - // Assert await waitFor(() => { expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id') }) @@ -255,17 +237,14 @@ describe('AppList', () => { }) }) - // Edge cases: handle clearing search keywords. describe('Edge Cases', () => { it('should reset search results when clear icon is clicked', async () => { - // Arrange mockExploreData = { categories: ['Writing'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], } renderWithContext() - // Act const input = screen.getByPlaceholderText('common.operation.search') fireEvent.change(input, { target: { value: 'gam' } }) await waitFor(() => { @@ -274,7 +253,6 @@ describe('AppList', () => { fireEvent.click(screen.getByTestId('input-clear')) - // Assert await waitFor(() => { expect(screen.getByText('Alpha')).toBeInTheDocument() expect(screen.getByText('Gamma')).toBeInTheDocument() diff --git a/web/app/components/explore/banner/banner-item.spec.tsx b/web/app/components/explore/banner/__tests__/banner-item.spec.tsx similarity index 91% rename from web/app/components/explore/banner/banner-item.spec.tsx rename to web/app/components/explore/banner/__tests__/banner-item.spec.tsx index c890c08dc5..de35814e8e 100644 --- a/web/app/components/explore/banner/banner-item.spec.tsx +++ b/web/app/components/explore/banner/__tests__/banner-item.spec.tsx @@ -1,7 +1,7 @@ import type { Banner } from '@/models/app' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { BannerItem } from './banner-item' +import { BannerItem } from '../banner-item' const mockScrollTo = vi.fn() const mockSlideNodes = vi.fn() @@ -16,17 +16,6 @@ vi.mock('@/app/components/base/carousel', () => ({ }), })) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record<string, string> = { - 'banner.viewMore': 'View More', - } - return translations[key] || key - }, - }), -})) - const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({ id: 'banner-1', status: 'enabled', @@ -40,14 +29,11 @@ const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({ ...overrides, } as Banner) -// Mock ResizeObserver methods declared at module level and initialized const mockResizeObserverObserve = vi.fn() const mockResizeObserverDisconnect = vi.fn() -// Create mock class outside of describe block for proper hoisting class MockResizeObserver { constructor(_callback: ResizeObserverCallback) { - // Store callback if needed } observe(...args: Parameters<ResizeObserver['observe']>) { @@ -59,7 +45,6 @@ class MockResizeObserver { } unobserve() { - // No-op } } @@ -72,7 +57,6 @@ describe('BannerItem', () => { vi.stubGlobal('ResizeObserver', MockResizeObserver) - // Mock window.innerWidth for responsive tests Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, @@ -147,7 +131,7 @@ describe('BannerItem', () => { />, ) - expect(screen.getByText('View More')).toBeInTheDocument() + expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument() }) }) @@ -257,7 +241,6 @@ describe('BannerItem', () => { />, ) - // Component should render without issues expect(screen.getByText('Test Banner Title')).toBeInTheDocument() }) @@ -271,7 +254,6 @@ describe('BannerItem', () => { />, ) - // Component should render with isPaused expect(screen.getByText('Test Banner Title')).toBeInTheDocument() }) }) @@ -320,7 +302,6 @@ describe('BannerItem', () => { }) it('sets maxWidth when window width is below breakpoint', () => { - // Set window width below RESPONSIVE_BREAKPOINT (1200) Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, @@ -335,12 +316,10 @@ describe('BannerItem', () => { />, ) - // Component should render and apply responsive styles expect(screen.getByText('Test Banner Title')).toBeInTheDocument() }) it('applies responsive styles when below breakpoint', () => { - // Set window width below RESPONSIVE_BREAKPOINT (1200) Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, @@ -355,8 +334,7 @@ describe('BannerItem', () => { />, ) - // The component should render even with responsive mode - expect(screen.getByText('View More')).toBeInTheDocument() + expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument() }) }) @@ -432,8 +410,6 @@ describe('BannerItem', () => { />, ) - // With selectedIndex=0 and 3 slides, nextIndex should be 1 - // The second indicator button should show the "next slide" state const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(3) }) diff --git a/web/app/components/explore/banner/banner.spec.tsx b/web/app/components/explore/banner/__tests__/banner.spec.tsx similarity index 94% rename from web/app/components/explore/banner/banner.spec.tsx rename to web/app/components/explore/banner/__tests__/banner.spec.tsx index de719c3936..d6d0aa44a8 100644 --- a/web/app/components/explore/banner/banner.spec.tsx +++ b/web/app/components/explore/banner/__tests__/banner.spec.tsx @@ -3,7 +3,7 @@ import type { Banner as BannerType } from '@/models/app' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import Banner from './banner' +import Banner from '../banner' const mockUseGetBanners = vi.fn() @@ -53,7 +53,7 @@ vi.mock('@/app/components/base/carousel', () => ({ }), })) -vi.mock('./banner-item', () => ({ +vi.mock('../banner-item', () => ({ BannerItem: ({ banner, autoplayDelay, isPaused }: { banner: BannerType autoplayDelay: number @@ -105,7 +105,6 @@ describe('Banner', () => { render(<Banner />) - // Loading component renders a spinner const loadingWrapper = document.querySelector('[style*="min-height"]') expect(loadingWrapper).toBeInTheDocument() }) @@ -266,7 +265,6 @@ describe('Banner', () => { const carousel = screen.getByTestId('carousel') - // Enter and then leave fireEvent.mouseEnter(carousel) fireEvent.mouseLeave(carousel) @@ -285,7 +283,6 @@ describe('Banner', () => { render(<Banner />) - // Trigger resize event act(() => { window.dispatchEvent(new Event('resize')) }) @@ -303,12 +300,10 @@ describe('Banner', () => { render(<Banner />) - // Trigger resize event act(() => { window.dispatchEvent(new Event('resize')) }) - // Wait for debounce delay (50ms) act(() => { vi.advanceTimersByTime(50) }) @@ -326,31 +321,25 @@ describe('Banner', () => { render(<Banner />) - // Trigger first resize event act(() => { window.dispatchEvent(new Event('resize')) }) - // Wait partial time act(() => { vi.advanceTimersByTime(30) }) - // Trigger second resize event act(() => { window.dispatchEvent(new Event('resize')) }) - // Wait another 30ms (total 60ms from second resize but only 30ms after) act(() => { vi.advanceTimersByTime(30) }) - // Should still be paused (debounce resets) let bannerItem = screen.getByTestId('banner-item') expect(bannerItem).toHaveAttribute('data-is-paused', 'true') - // Wait remaining time act(() => { vi.advanceTimersByTime(20) }) @@ -388,7 +377,6 @@ describe('Banner', () => { const { unmount } = render(<Banner />) - // Trigger resize to create timer act(() => { window.dispatchEvent(new Event('resize')) }) @@ -462,10 +450,8 @@ describe('Banner', () => { const { rerender } = render(<Banner />) - // Re-render with same props rerender(<Banner />) - // Component should still be present (memo doesn't break rendering) expect(screen.getByTestId('carousel')).toBeInTheDocument() }) }) diff --git a/web/app/components/explore/banner/indicator-button.spec.tsx b/web/app/components/explore/banner/__tests__/indicator-button.spec.tsx similarity index 92% rename from web/app/components/explore/banner/indicator-button.spec.tsx rename to web/app/components/explore/banner/__tests__/indicator-button.spec.tsx index 545f4e2f9a..4c391e7b5e 100644 --- a/web/app/components/explore/banner/indicator-button.spec.tsx +++ b/web/app/components/explore/banner/__tests__/indicator-button.spec.tsx @@ -1,7 +1,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { IndicatorButton } from './indicator-button' +import { IndicatorButton } from '../indicator-button' describe('IndicatorButton', () => { beforeEach(() => { @@ -164,7 +164,6 @@ describe('IndicatorButton', () => { />, ) - // Check for conic-gradient style which indicates progress indicator const progressIndicator = container.querySelector('[style*="conic-gradient"]') expect(progressIndicator).not.toBeInTheDocument() }) @@ -221,10 +220,8 @@ describe('IndicatorButton', () => { />, ) - // Initially no progress indicator expect(container.querySelector('[style*="conic-gradient"]')).not.toBeInTheDocument() - // Rerender with isNextSlide=true rerender( <IndicatorButton index={1} @@ -237,7 +234,6 @@ describe('IndicatorButton', () => { />, ) - // Now progress indicator should be visible expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument() }) @@ -255,11 +251,9 @@ describe('IndicatorButton', () => { />, ) - // Progress indicator should be present const progressIndicator = container.querySelector('[style*="conic-gradient"]') expect(progressIndicator).toBeInTheDocument() - // Rerender with new resetKey - this should reset the progress animation rerender( <IndicatorButton index={1} @@ -273,7 +267,6 @@ describe('IndicatorButton', () => { ) const newProgressIndicator = container.querySelector('[style*="conic-gradient"]') - // The progress indicator should still be present after reset expect(newProgressIndicator).toBeInTheDocument() }) @@ -293,8 +286,6 @@ describe('IndicatorButton', () => { />, ) - // The component should still render but animation should be paused - // requestAnimationFrame might still be called for polling but progress won't update expect(screen.getByRole('button')).toBeInTheDocument() mockRequestAnimationFrame.mockRestore() }) @@ -315,7 +306,6 @@ describe('IndicatorButton', () => { />, ) - // Trigger animation frame act(() => { vi.advanceTimersToNextTimer() }) @@ -342,12 +332,10 @@ describe('IndicatorButton', () => { />, ) - // Trigger animation frame act(() => { vi.advanceTimersToNextTimer() }) - // Change isNextSlide to false - this should cancel the animation frame rerender( <IndicatorButton index={1} @@ -368,7 +356,6 @@ describe('IndicatorButton', () => { const mockOnClick = vi.fn() const mockRequestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame') - // Mock document.hidden to be true Object.defineProperty(document, 'hidden', { writable: true, configurable: true, @@ -387,10 +374,8 @@ describe('IndicatorButton', () => { />, ) - // Component should still render expect(screen.getByRole('button')).toBeInTheDocument() - // Reset document.hidden Object.defineProperty(document, 'hidden', { writable: true, configurable: true, @@ -415,7 +400,6 @@ describe('IndicatorButton', () => { />, ) - // Progress indicator should be visible (animation running) expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument() }) }) diff --git a/web/app/components/explore/create-app-modal/index.spec.tsx b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx similarity index 74% rename from web/app/components/explore/create-app-modal/index.spec.tsx rename to web/app/components/explore/create-app-modal/__tests__/index.spec.tsx index 65ec0e6096..62353fb3c1 100644 --- a/web/app/components/explore/create-app-modal/index.spec.tsx +++ b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx @@ -1,43 +1,12 @@ -import type { CreateAppModalProps } from './index' +import type { CreateAppModalProps } from '../index' import type { UsagePlanInfo } from '@/app/components/billing/type' import { act, fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { createMockPlan, createMockPlanTotal, createMockPlanUsage } from '@/__mocks__/provider-context' import { Plan } from '@/app/components/billing/type' import { AppModeEnum } from '@/types/app' -import CreateAppModal from './index' +import CreateAppModal from '../index' -let mockTranslationOverrides: Record<string, string | undefined> = {} - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record<string, unknown>) => { - const override = mockTranslationOverrides[key] - if (override !== undefined) - return override - if (options?.returnObjects) - return [`${key}-feature-1`, `${key}-feature-2`] - if (options) { - const { ns, ...rest } = options - const prefix = ns ? `${ns}.` : '' - const suffix = Object.keys(rest).length > 0 ? `:${JSON.stringify(rest)}` : '' - return `${prefix}${key}${suffix}` - } - return key - }, - i18n: { - language: 'en', - changeLanguage: vi.fn(), - }, - }), - Trans: ({ children }: { children?: React.ReactNode }) => children, - initReactI18next: { - type: '3rdParty', - init: vi.fn(), - }, -})) - -// Avoid heavy emoji dataset initialization during unit tests. vi.mock('emoji-mart', () => ({ init: vi.fn(), SearchIndex: { search: vi.fn().mockResolvedValue([]) }, @@ -87,7 +56,7 @@ vi.mock('@/context/provider-context', () => ({ type ConfirmPayload = Parameters<CreateAppModalProps['onConfirm']>[0] -const setup = (overrides: Partial<CreateAppModalProps> = {}) => { +const setup = async (overrides: Partial<CreateAppModalProps> = {}) => { const onConfirm = vi.fn<(payload: ConfirmPayload) => Promise<void>>().mockResolvedValue(undefined) const onHide = vi.fn() @@ -109,7 +78,9 @@ const setup = (overrides: Partial<CreateAppModalProps> = {}) => { ...overrides, } - render(<CreateAppModal {...props} />) + await act(async () => { + render(<CreateAppModal {...props} />) + }) return { onConfirm, onHide } } @@ -125,25 +96,23 @@ const getAppIconTrigger = (): HTMLElement => { describe('CreateAppModal', () => { beforeEach(() => { vi.clearAllMocks() - mockTranslationOverrides = {} mockEnableBilling = false mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(1) mockTotalPlanInfo = createPlanInfo(10) }) - // The title and form sections vary based on the modal mode (create vs edit). describe('Rendering', () => { - it('should render create title and actions when creating', () => { - setup({ appName: 'My App', isEditModal: false }) + it('should render create title and actions when creating', async () => { + await setup({ appName: 'My App', isEditModal: false }) expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() }) - it('should render edit-only fields when editing a chat app', () => { - setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 }) + it('should render edit-only fields when editing a chat app', async () => { + await setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 }) expect(screen.getByText('app.editAppTitle')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeInTheDocument() @@ -151,65 +120,57 @@ describe('CreateAppModal', () => { expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5') }) - it.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', (mode) => { - setup({ isEditModal: true, appMode: mode }) + it.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', async (mode) => { + await setup({ isEditModal: true, appMode: mode }) expect(screen.getByRole('switch')).toBeInTheDocument() }) - it('should not render answer icon switch when editing a non-chat app', () => { - setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION }) + it('should not render answer icon switch when editing a non-chat app', async () => { + await setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION }) expect(screen.queryByRole('switch')).not.toBeInTheDocument() }) - it('should not render modal content when hidden', () => { - setup({ show: false }) + it('should not render modal content when hidden', async () => { + await setup({ show: false }) expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument() }) }) - // Disabled states prevent submission and reflect parent-driven props. describe('Props', () => { - it('should disable confirm action when confirmDisabled is true', () => { - setup({ confirmDisabled: true }) + it('should disable confirm action when confirmDisabled is true', async () => { + await setup({ confirmDisabled: true }) expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) - it('should disable confirm action when appName is empty', () => { - setup({ appName: ' ' }) + it('should disable confirm action when appName is empty', async () => { + await setup({ appName: ' ' }) expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) }) - // Defensive coverage for falsy input values and translation edge cases. describe('Edge Cases', () => { - it('should default description to empty string when appDescription is empty', () => { - setup({ appDescription: '' }) + it('should default description to empty string when appDescription is empty', async () => { + await setup({ appDescription: '' }) expect((screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder') as HTMLTextAreaElement).value).toBe('') }) - it('should fall back to empty placeholders when translations return empty string', () => { - mockTranslationOverrides = { - 'newApp.appNamePlaceholder': '', - 'newApp.appDescriptionPlaceholder': '', - } + it('should render i18n key placeholders when translations are available', async () => { + await setup() - setup() - - expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('') - expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('') + expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('app.newApp.appNamePlaceholder') + expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('app.newApp.appDescriptionPlaceholder') }) }) - // The modal should close from user-initiated cancellation actions. describe('User Interactions', () => { - it('should call onHide when cancel button is clicked', () => { - const { onConfirm, onHide } = setup() + it('should call onHide when cancel button is clicked', async () => { + const { onConfirm, onHide } = await setup() fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) @@ -217,16 +178,16 @@ describe('CreateAppModal', () => { expect(onConfirm).not.toHaveBeenCalled() }) - it('should call onHide when pressing Escape while visible', () => { - const { onHide } = setup() + it('should call onHide when pressing Escape while visible', async () => { + const { onHide } = await setup() fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 }) expect(onHide).toHaveBeenCalledTimes(1) }) - it('should not call onHide when pressing Escape while hidden', () => { - const { onHide } = setup({ show: false }) + it('should not call onHide when pressing Escape while hidden', async () => { + const { onHide } = await setup({ show: false }) fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 }) @@ -234,34 +195,32 @@ describe('CreateAppModal', () => { }) }) - // When billing limits are reached, the modal blocks app creation and shows quota guidance. describe('Quota Gating', () => { - it('should show AppsFull and disable create when apps quota is reached', () => { + it('should show AppsFull and disable create when apps quota is reached', async () => { mockEnableBilling = true mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(10) mockTotalPlanInfo = createPlanInfo(10) - setup({ isEditModal: false }) + await setup({ isEditModal: false }) expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) - it('should allow saving when apps quota is reached in edit mode', () => { + it('should allow saving when apps quota is reached in edit mode', async () => { mockEnableBilling = true mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(10) mockTotalPlanInfo = createPlanInfo(10) - setup({ isEditModal: true }) + await setup({ isEditModal: true }) expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeEnabled() }) }) - // Shortcut handlers are important for power users and must respect gating rules. describe('Keyboard Shortcuts', () => { beforeEach(() => { vi.useFakeTimers() @@ -274,11 +233,11 @@ describe('CreateAppModal', () => { it.each([ ['meta+enter', { metaKey: true }], ['ctrl+enter', { ctrlKey: true }], - ])('should submit when %s is pressed while visible', (_, modifier) => { - const { onConfirm, onHide } = setup() + ])('should submit when %s is pressed while visible', async (_, modifier) => { + const { onConfirm, onHide } = await setup() fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, ...modifier }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -286,11 +245,11 @@ describe('CreateAppModal', () => { expect(onHide).toHaveBeenCalledTimes(1) }) - it('should not submit when modal is hidden', () => { - const { onConfirm, onHide } = setup({ show: false }) + it('should not submit when modal is hidden', async () => { + const { onConfirm, onHide } = await setup({ show: false }) fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -298,16 +257,16 @@ describe('CreateAppModal', () => { expect(onHide).not.toHaveBeenCalled() }) - it('should not submit when apps quota is reached in create mode', () => { + it('should not submit when apps quota is reached in create mode', async () => { mockEnableBilling = true mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(10) mockTotalPlanInfo = createPlanInfo(10) - const { onConfirm, onHide } = setup({ isEditModal: false }) + const { onConfirm, onHide } = await setup({ isEditModal: false }) fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -315,16 +274,16 @@ describe('CreateAppModal', () => { expect(onHide).not.toHaveBeenCalled() }) - it('should submit when apps quota is reached in edit mode', () => { + it('should submit when apps quota is reached in edit mode', async () => { mockEnableBilling = true mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(10) mockTotalPlanInfo = createPlanInfo(10) - const { onConfirm, onHide } = setup({ isEditModal: true }) + const { onConfirm, onHide } = await setup({ isEditModal: true }) fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -332,11 +291,11 @@ describe('CreateAppModal', () => { expect(onHide).toHaveBeenCalledTimes(1) }) - it('should not submit when name is empty', () => { - const { onConfirm, onHide } = setup({ appName: ' ' }) + it('should not submit when name is empty', async () => { + const { onConfirm, onHide } = await setup({ appName: ' ' }) fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -345,10 +304,9 @@ describe('CreateAppModal', () => { }) }) - // The app icon picker is a key user flow for customizing metadata. describe('App Icon Picker', () => { - it('should open and close the picker when cancel is clicked', () => { - setup({ + it('should open and close the picker when cancel is clicked', async () => { + await setup({ appIconType: 'image', appIcon: 'file-123', appIconUrl: 'https://example.com/icon.png', @@ -363,10 +321,10 @@ describe('CreateAppModal', () => { expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() }) - it('should update icon payload when selecting emoji and confirming', () => { + it('should update icon payload when selecting emoji and confirming', async () => { vi.useFakeTimers() try { - const { onConfirm } = setup({ + const { onConfirm } = await setup({ appIconType: 'image', appIcon: 'file-123', appIconUrl: 'https://example.com/icon.png', @@ -374,7 +332,6 @@ describe('CreateAppModal', () => { fireEvent.click(getAppIconTrigger()) - // Find the emoji grid by locating the category label, then find the clickable emoji wrapper const categoryLabel = screen.getByText('people') const emojiGrid = categoryLabel.nextElementSibling const clickableEmojiWrapper = emojiGrid?.firstElementChild @@ -385,7 +342,7 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' })) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -402,19 +359,17 @@ describe('CreateAppModal', () => { } }) - it('should reset emoji icon to initial props when picker is cancelled', () => { + it('should reset emoji icon to initial props when picker is cancelled', async () => { vi.useFakeTimers() try { - const { onConfirm } = setup({ + const { onConfirm } = await setup({ appIconType: 'emoji', appIcon: 'đŸ€–', appIconBackground: '#FFEAD5', }) - // Open picker, select a new emoji, and confirm fireEvent.click(getAppIconTrigger()) - // Find the emoji grid by locating the category label, then find the clickable emoji wrapper const categoryLabel = screen.getByText('people') const emojiGrid = categoryLabel.nextElementSibling const clickableEmojiWrapper = emojiGrid?.firstElementChild @@ -426,15 +381,13 @@ describe('CreateAppModal', () => { expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() - // Open picker again and cancel - should reset to initial props fireEvent.click(getAppIconTrigger()) fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' })) expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() - // Submit and verify the payload uses the original icon (cancel reverts to props) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -452,7 +405,6 @@ describe('CreateAppModal', () => { }) }) - // Submitting uses a debounced handler and builds a payload from current form state. describe('Submitting', () => { beforeEach(() => { vi.useFakeTimers() @@ -462,8 +414,8 @@ describe('CreateAppModal', () => { vi.useRealTimers() }) - it('should call onConfirm with emoji payload and hide when create is clicked', () => { - const { onConfirm, onHide } = setup({ + it('should call onConfirm with emoji payload and hide when create is clicked', async () => { + const { onConfirm, onHide } = await setup({ appName: 'My App', appDescription: 'My description', appIconType: 'emoji', @@ -472,7 +424,7 @@ describe('CreateAppModal', () => { }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -491,12 +443,12 @@ describe('CreateAppModal', () => { expect(payload).not.toHaveProperty('max_active_requests') }) - it('should include updated description when textarea is changed before submitting', () => { - const { onConfirm } = setup({ appDescription: 'Old description' }) + it('should include updated description when textarea is changed before submitting', async () => { + const { onConfirm } = await setup({ appDescription: 'Old description' }) fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -504,8 +456,8 @@ describe('CreateAppModal', () => { expect(onConfirm.mock.calls[0][0]).toMatchObject({ description: 'Updated description' }) }) - it('should omit icon_background when submitting with image icon', () => { - const { onConfirm } = setup({ + it('should omit icon_background when submitting with image icon', async () => { + const { onConfirm } = await setup({ appIconType: 'image', appIcon: 'file-123', appIconUrl: 'https://example.com/icon.png', @@ -513,7 +465,7 @@ describe('CreateAppModal', () => { }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -525,8 +477,8 @@ describe('CreateAppModal', () => { expect(payload.icon_background).toBeUndefined() }) - it('should include max_active_requests and updated answer icon when saving', () => { - const { onConfirm } = setup({ + it('should include max_active_requests and updated answer icon when saving', async () => { + const { onConfirm } = await setup({ isEditModal: true, appMode: AppModeEnum.CHAT, appUseIconAsAnswerIcon: false, @@ -537,7 +489,7 @@ describe('CreateAppModal', () => { fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -548,11 +500,11 @@ describe('CreateAppModal', () => { }) }) - it('should omit max_active_requests when input is empty', () => { - const { onConfirm } = setup({ isEditModal: true, max_active_requests: null }) + it('should omit max_active_requests when input is empty', async () => { + const { onConfirm } = await setup({ isEditModal: true, max_active_requests: null }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -560,12 +512,12 @@ describe('CreateAppModal', () => { expect(payload.max_active_requests).toBeUndefined() }) - it('should omit max_active_requests when input is not a number', () => { - const { onConfirm } = setup({ isEditModal: true, max_active_requests: null }) + it('should omit max_active_requests when input is not a number', async () => { + const { onConfirm } = await setup({ isEditModal: true, max_active_requests: null }) fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) @@ -573,18 +525,18 @@ describe('CreateAppModal', () => { expect(payload.max_active_requests).toBeUndefined() }) - it('should show toast error and not submit when name becomes empty before debounced submit runs', () => { - const { onConfirm, onHide } = setup({ appName: 'My App' }) + it('should show toast error and not submit when name becomes empty before debounced submit runs', async () => { + const { onConfirm, onHide } = await setup({ appName: 'My App' }) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } }) - act(() => { + await act(async () => { vi.advanceTimersByTime(300) }) expect(screen.getByText('explore.appCustomize.nameRequired')).toBeInTheDocument() - act(() => { + await act(async () => { vi.advanceTimersByTime(6000) }) expect(screen.queryByText('explore.appCustomize.nameRequired')).not.toBeInTheDocument() diff --git a/web/app/components/explore/installed-app/index.spec.tsx b/web/app/components/explore/installed-app/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/explore/installed-app/index.spec.tsx rename to web/app/components/explore/installed-app/__tests__/index.spec.tsx index 6d2bcb526a..eca7b3139d 100644 --- a/web/app/components/explore/installed-app/index.spec.tsx +++ b/web/app/components/explore/installed-app/__tests__/index.spec.tsx @@ -8,9 +8,8 @@ import { AccessMode } from '@/models/access-control' import { useGetUserCanAccessApp } from '@/service/access-control' import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore' import { AppModeEnum } from '@/types/app' -import InstalledApp from './index' +import InstalledApp from '../index' -// Mock external dependencies BEFORE imports vi.mock('use-context-selector', () => ({ useContext: vi.fn(), createContext: vi.fn(() => ({})), @@ -119,13 +118,11 @@ describe('InstalledApp', () => { beforeEach(() => { vi.clearAllMocks() - // Mock useContext ;(useContext as Mock).mockReturnValue({ installedApps: [mockInstalledApp], isFetchingInstalledApps: false, }) - // Mock useWebAppStore ;(useWebAppStore as unknown as Mock).mockImplementation(( selector: (state: { updateAppInfo: Mock @@ -145,7 +142,6 @@ describe('InstalledApp', () => { return selector(state) }) - // Mock service hooks with default success states ;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({ isFetching: false, data: mockWebAppAccessMode, @@ -565,7 +561,6 @@ describe('InstalledApp', () => { }) render(<InstalledApp id="installed-app-123" />) - // Should find and render the correct app expect(screen.getByText(/Chat With History/i)).toBeInTheDocument() expect(screen.getByText(/installed-app-123/)).toBeInTheDocument() }) @@ -624,7 +619,6 @@ describe('InstalledApp', () => { }) render(<InstalledApp id="installed-app-123" />) - // Error should take precedence over loading expect(screen.getByText(/Some error/)).toBeInTheDocument() }) @@ -640,7 +634,6 @@ describe('InstalledApp', () => { }) render(<InstalledApp id="installed-app-123" />) - // Error should take precedence over permission expect(screen.getByText(/Params error/)).toBeInTheDocument() expect(screen.queryByText(/403/)).not.toBeInTheDocument() }) @@ -656,7 +649,6 @@ describe('InstalledApp', () => { }) render(<InstalledApp id="nonexistent-app" />) - // Permission should take precedence over 404 expect(screen.getByText(/403/)).toBeInTheDocument() expect(screen.queryByText(/404/)).not.toBeInTheDocument() }) @@ -673,7 +665,6 @@ describe('InstalledApp', () => { }) const { container } = render(<InstalledApp id="nonexistent-app" />) - // Loading should take precedence over 404 const svg = container.querySelector('svg.spin-animation') expect(svg).toBeInTheDocument() expect(screen.queryByText(/404/)).not.toBeInTheDocument() diff --git a/web/app/components/explore/item-operation/index.spec.tsx b/web/app/components/explore/item-operation/__tests__/index.spec.tsx similarity index 83% rename from web/app/components/explore/item-operation/index.spec.tsx rename to web/app/components/explore/item-operation/__tests__/index.spec.tsx index 9084e5564e..f7f9b44a84 100644 --- a/web/app/components/explore/item-operation/index.spec.tsx +++ b/web/app/components/explore/item-operation/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import ItemOperation from './index' +import ItemOperation from '../index' describe('ItemOperation', () => { beforeEach(() => { @@ -20,87 +20,65 @@ describe('ItemOperation', () => { } } - // Rendering: menu items show after opening. describe('Rendering', () => { it('should render pin and delete actions when menu is open', async () => { - // Arrange renderComponent() - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) - // Assert expect(await screen.findByText('explore.sidebar.action.pin')).toBeInTheDocument() expect(screen.getByText('explore.sidebar.action.delete')).toBeInTheDocument() }) }) - // Props: render optional rename action and pinned label text. describe('Props', () => { it('should render rename action when isShowRenameConversation is true', async () => { - // Arrange renderComponent({ isShowRenameConversation: true }) - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) - // Assert expect(await screen.findByText('explore.sidebar.action.rename')).toBeInTheDocument() }) it('should render unpin label when isPinned is true', async () => { - // Arrange renderComponent({ isPinned: true }) - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) - // Assert expect(await screen.findByText('explore.sidebar.action.unpin')).toBeInTheDocument() }) }) - // User interactions: clicking action items triggers callbacks. describe('User Interactions', () => { it('should call togglePin when clicking pin action', async () => { - // Arrange const { props } = renderComponent() - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) - // Assert expect(props.togglePin).toHaveBeenCalledTimes(1) }) it('should call onDelete when clicking delete action', async () => { - // Arrange const { props } = renderComponent() - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) - // Assert expect(props.onDelete).toHaveBeenCalledTimes(1) }) }) - // Edge cases: menu closes after mouse leave when no hovering state remains. describe('Edge Cases', () => { it('should close the menu when mouse leaves the panel and item is not hovering', async () => { - // Arrange renderComponent() fireEvent.click(screen.getByTestId('item-operation-trigger')) const pinText = await screen.findByText('explore.sidebar.action.pin') const menu = pinText.closest('div')?.parentElement as HTMLElement - // Act fireEvent.mouseEnter(menu) fireEvent.mouseLeave(menu) - // Assert await waitFor(() => { expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument() }) diff --git a/web/app/components/explore/sidebar/index.spec.tsx b/web/app/components/explore/sidebar/__tests__/index.spec.tsx similarity index 62% rename from web/app/components/explore/sidebar/index.spec.tsx rename to web/app/components/explore/sidebar/__tests__/index.spec.tsx index e06cefd40b..2fcc48fc56 100644 --- a/web/app/components/explore/sidebar/index.spec.tsx +++ b/web/app/components/explore/sidebar/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import Toast from '@/app/components/base/toast' import ExploreContext from '@/context/explore-context' import { MediaType } from '@/hooks/use-breakpoints' import { AppModeEnum } from '@/types/app' -import SideBar from './index' +import SideBar from '../index' const mockSegments = ['apps'] const mockPush = vi.fn() @@ -14,6 +14,7 @@ const mockUninstall = vi.fn() const mockUpdatePinStatus = vi.fn() let mockIsFetching = false let mockInstalledApps: InstalledApp[] = [] +let mockMediaType: string = MediaType.pc vi.mock('next/navigation', () => ({ useSelectedLayoutSegments: () => mockSegments, @@ -23,7 +24,7 @@ vi.mock('next/navigation', () => ({ })) vi.mock('@/hooks/use-breakpoints', () => ({ - default: () => MediaType.pc, + default: () => mockMediaType, MediaType: { mobile: 'mobile', tablet: 'tablet', @@ -85,53 +86,73 @@ describe('SideBar', () => { vi.clearAllMocks() mockIsFetching = false mockInstalledApps = [] + mockMediaType = MediaType.pc vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) }) - // Rendering: show discovery and workspace section. describe('Rendering', () => { - it('should render workspace items when installed apps exist', () => { - // Arrange - mockInstalledApps = [createInstalledApp()] + it('should render discovery link', () => { + renderWithContext() - // Act + expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument() + }) + + it('should render workspace items when installed apps exist', () => { + mockInstalledApps = [createInstalledApp()] renderWithContext(mockInstalledApps) - // Assert - expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument() expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument() expect(screen.getByText('My App')).toBeInTheDocument() }) - }) - // Effects: refresh and sync installed apps state. - describe('Effects', () => { - it('should refetch installed apps on mount', () => { - // Arrange - mockInstalledApps = [createInstalledApp()] + it('should render NoApps component when no installed apps on desktop', () => { + renderWithContext([]) - // Act + expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument() + }) + + it('should render multiple installed apps', () => { + mockInstalledApps = [ + createInstalledApp({ id: 'app-1', app: { ...createInstalledApp().app, name: 'Alpha' } }), + createInstalledApp({ id: 'app-2', app: { ...createInstalledApp().app, name: 'Beta' } }), + ] + renderWithContext(mockInstalledApps) + + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('Beta')).toBeInTheDocument() + }) + + it('should render divider between pinned and unpinned apps', () => { + mockInstalledApps = [ + createInstalledApp({ id: 'app-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned' } }), + createInstalledApp({ id: 'app-2', is_pinned: false, app: { ...createInstalledApp().app, name: 'Unpinned' } }), + ] + const { container } = renderWithContext(mockInstalledApps) + + const dividers = container.querySelectorAll('[class*="divider"], hr') + expect(dividers.length).toBeGreaterThan(0) + }) + }) + + describe('Effects', () => { + it('should refetch installed apps on mount', () => { + mockInstalledApps = [createInstalledApp()] renderWithContext(mockInstalledApps) - // Assert expect(mockRefetch).toHaveBeenCalledTimes(1) }) }) - // User interactions: delete and pin flows. describe('User Interactions', () => { it('should uninstall app and show toast when delete is confirmed', async () => { - // Arrange mockInstalledApps = [createInstalledApp()] mockUninstall.mockResolvedValue(undefined) renderWithContext(mockInstalledApps) - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) fireEvent.click(await screen.findByText('common.operation.confirm')) - // Assert await waitFor(() => { expect(mockUninstall).toHaveBeenCalledWith('app-123') expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ @@ -142,16 +163,13 @@ describe('SideBar', () => { }) it('should update pin status and show toast when pin is clicked', async () => { - // Arrange mockInstalledApps = [createInstalledApp({ is_pinned: false })] mockUpdatePinStatus.mockResolvedValue(undefined) renderWithContext(mockInstalledApps) - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) - // Assert await waitFor(() => { expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true }) expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ @@ -160,5 +178,44 @@ describe('SideBar', () => { })) }) }) + + it('should unpin an already pinned app', async () => { + mockInstalledApps = [createInstalledApp({ is_pinned: true })] + mockUpdatePinStatus.mockResolvedValue(undefined) + renderWithContext(mockInstalledApps) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.unpin')) + + await waitFor(() => { + expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: false }) + }) + }) + + it('should open and close confirm dialog for delete', async () => { + mockInstalledApps = [createInstalledApp()] + renderWithContext(mockInstalledApps) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.operation.cancel')) + + await waitFor(() => { + expect(mockUninstall).not.toHaveBeenCalled() + }) + }) + }) + + describe('Edge Cases', () => { + it('should hide NoApps and app names on mobile', () => { + mockMediaType = MediaType.mobile + renderWithContext([]) + + expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument() + expect(screen.queryByText('explore.sidebar.webApps')).not.toBeInTheDocument() + }) }) }) diff --git a/web/app/components/explore/sidebar/app-nav-item/index.spec.tsx b/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx similarity index 83% rename from web/app/components/explore/sidebar/app-nav-item/index.spec.tsx rename to web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx index 542ecf33c2..299c181c98 100644 --- a/web/app/components/explore/sidebar/app-nav-item/index.spec.tsx +++ b/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import AppNavItem from './index' +import AppNavItem from '../index' const mockPush = vi.fn() @@ -37,62 +37,46 @@ describe('AppNavItem', () => { vi.clearAllMocks() }) - // Rendering: display app name for desktop and hide for mobile. describe('Rendering', () => { it('should render name and item operation on desktop', () => { - // Arrange render(<AppNavItem {...baseProps} />) - // Assert expect(screen.getByText('My App')).toBeInTheDocument() expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument() }) it('should hide name on mobile', () => { - // Arrange render(<AppNavItem {...baseProps} isMobile />) - // Assert expect(screen.queryByText('My App')).not.toBeInTheDocument() }) }) - // User interactions: navigation and delete flow. describe('User Interactions', () => { it('should navigate to installed app when item is clicked', () => { - // Arrange render(<AppNavItem {...baseProps} />) - // Act fireEvent.click(screen.getByText('My App')) - // Assert expect(mockPush).toHaveBeenCalledWith('/explore/installed/app-123') }) it('should call onDelete with app id when delete action is clicked', async () => { - // Arrange render(<AppNavItem {...baseProps} />) - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) - // Assert expect(baseProps.onDelete).toHaveBeenCalledWith('app-123') }) }) - // Edge cases: hide delete when uninstallable or selected. describe('Edge Cases', () => { it('should not render delete action when app is uninstallable', () => { - // Arrange render(<AppNavItem {...baseProps} uninstallable />) - // Act fireEvent.click(screen.getByTestId('item-operation-trigger')) - // Assert expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/explore/sidebar/no-apps/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/no-apps/__tests__/index.spec.tsx new file mode 100644 index 0000000000..d4c37b8be5 --- /dev/null +++ b/web/app/components/explore/sidebar/no-apps/__tests__/index.spec.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react' +import { Theme } from '@/types/app' +import NoApps from '../index' + +let mockTheme = Theme.light + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: mockTheme }), +})) + +describe('NoApps', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = Theme.light + }) + + describe('Rendering', () => { + it('should render title, description and learn-more link', () => { + render(<NoApps />) + + expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.noApps.description')).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.noApps.learnMore')).toBeInTheDocument() + }) + + it('should render learn-more as external link with correct href', () => { + render(<NoApps />) + + const link = screen.getByText('explore.sidebar.noApps.learnMore') + expect(link.tagName).toBe('A') + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/publish/README') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + describe('Theme', () => { + it('should apply light theme background class in light mode', () => { + mockTheme = Theme.light + + const { container } = render(<NoApps />) + const bgDiv = container.querySelector('[class*="bg-contain"]') + + expect(bgDiv).toBeInTheDocument() + expect(bgDiv?.className).toContain('light') + expect(bgDiv?.className).not.toContain('dark') + }) + + it('should apply dark theme background class in dark mode', () => { + mockTheme = Theme.dark + + const { container } = render(<NoApps />) + const bgDiv = container.querySelector('[class*="bg-contain"]') + + expect(bgDiv).toBeInTheDocument() + expect(bgDiv?.className).toContain('dark') + }) + }) +}) diff --git a/web/app/components/explore/try-app/index.spec.tsx b/web/app/components/explore/try-app/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/explore/try-app/index.spec.tsx rename to web/app/components/explore/try-app/__tests__/index.spec.tsx index dc057b4d9f..44a413bbad 100644 --- a/web/app/components/explore/try-app/index.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx @@ -1,20 +1,8 @@ import type { TryAppInfo } from '@/service/try-app' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import TryApp from './index' -import { TypeEnum } from './tab' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record<string, string> = { - 'tryApp.tabHeader.try': 'Try', - 'tryApp.tabHeader.detail': 'Detail', - } - return translations[key] || key - }, - }), -})) +import TryApp from '../index' +import { TypeEnum } from '../tab' vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() as object @@ -30,7 +18,7 @@ vi.mock('@/service/use-try-app', () => ({ useGetTryAppInfo: (...args: unknown[]) => mockUseGetTryAppInfo(...args), })) -vi.mock('./app', () => ({ +vi.mock('../app', () => ({ default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => ( <div data-testid="app-component" data-app-id={appId} data-mode={appDetail?.mode}> App Component @@ -38,7 +26,7 @@ vi.mock('./app', () => ({ ), })) -vi.mock('./preview', () => ({ +vi.mock('../preview', () => ({ default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => ( <div data-testid="preview-component" data-app-id={appId} data-mode={appDetail?.mode}> Preview Component @@ -46,7 +34,7 @@ vi.mock('./preview', () => ({ ), })) -vi.mock('./app-info', () => ({ +vi.mock('../app-info', () => ({ default: ({ appId, appDetail, @@ -141,8 +129,8 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - expect(screen.getByText('Try')).toBeInTheDocument() - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) }) @@ -185,7 +173,6 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - // Find the close button (the one with RiCloseLine icon) const buttons = document.body.querySelectorAll('button') expect(buttons.length).toBeGreaterThan(0) }) @@ -203,10 +190,10 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) - fireEvent.click(screen.getByText('Detail')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) await waitFor(() => { expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument() @@ -224,18 +211,16 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) - // First switch to Detail - fireEvent.click(screen.getByText('Detail')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) await waitFor(() => { expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument() }) - // Then switch back to Try - fireEvent.click(screen.getByText('Try')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try')) await waitFor(() => { expect(document.body.querySelector('[data-testid="app-component"]')).toBeInTheDocument() @@ -256,7 +241,6 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - // Find the button with close icon const buttons = document.body.querySelectorAll('button') const closeButton = Array.from(buttons).find(btn => btn.querySelector('svg') || btn.className.includes('rounded-[10px]'), @@ -368,10 +352,10 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) - fireEvent.click(screen.getByText('Detail')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) await waitFor(() => { const previewComponent = document.body.querySelector('[data-testid="preview-component"]') diff --git a/web/app/components/explore/try-app/tab.spec.tsx b/web/app/components/explore/try-app/__tests__/tab.spec.tsx similarity index 65% rename from web/app/components/explore/try-app/tab.spec.tsx rename to web/app/components/explore/try-app/__tests__/tab.spec.tsx index af64a93f43..9a7f04b81d 100644 --- a/web/app/components/explore/try-app/tab.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/tab.spec.tsx @@ -1,18 +1,6 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import Tab, { TypeEnum } from './tab' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record<string, string> = { - 'tryApp.tabHeader.try': 'Try', - 'tryApp.tabHeader.detail': 'Detail', - } - return translations[key] || key - }, - }), -})) +import Tab, { TypeEnum } from '../tab' vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() as object @@ -31,23 +19,23 @@ describe('Tab', () => { const mockOnChange = vi.fn() render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />) - expect(screen.getByText('Try')).toBeInTheDocument() - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) it('renders tab with DETAIL value selected', () => { const mockOnChange = vi.fn() render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />) - expect(screen.getByText('Try')).toBeInTheDocument() - expect(screen.getByText('Detail')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() }) it('calls onChange when clicking a tab', () => { const mockOnChange = vi.fn() render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />) - fireEvent.click(screen.getByText('Detail')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.DETAIL) }) @@ -55,7 +43,7 @@ describe('Tab', () => { const mockOnChange = vi.fn() render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />) - fireEvent.click(screen.getByText('Try')) + fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try')) expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.TRY) }) diff --git a/web/app/components/explore/try-app/app-info/index.spec.tsx b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/explore/try-app/app-info/index.spec.tsx rename to web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx index cfae862a72..a49e9379f0 100644 --- a/web/app/components/explore/try-app/app-info/index.spec.tsx +++ b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx @@ -1,29 +1,11 @@ import type { TryAppInfo } from '@/service/try-app' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import AppInfo from './index' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record<string, string> = { - 'types.advanced': 'Advanced', - 'types.chatbot': 'Chatbot', - 'types.agent': 'Agent', - 'types.workflow': 'Workflow', - 'types.completion': 'Completion', - 'tryApp.createFromSampleApp': 'Create from Sample', - 'tryApp.category': 'Category', - 'tryApp.requirements': 'Requirements', - } - return translations[key] || key - }, - }), -})) +import AppInfo from '../index' const mockUseGetRequirements = vi.fn() -vi.mock('./use-get-requirements', () => ({ +vi.mock('../use-get-requirements', () => ({ default: (...args: unknown[]) => mockUseGetRequirements(...args), })) @@ -118,7 +100,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('ADVANCED')).toBeInTheDocument() + expect(screen.getByText('APP.TYPES.ADVANCED')).toBeInTheDocument() }) it('displays CHATBOT for chat mode', () => { @@ -133,7 +115,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('CHATBOT')).toBeInTheDocument() + expect(screen.getByText('APP.TYPES.CHATBOT')).toBeInTheDocument() }) it('displays AGENT for agent-chat mode', () => { @@ -148,7 +130,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('AGENT')).toBeInTheDocument() + expect(screen.getByText('APP.TYPES.AGENT')).toBeInTheDocument() }) it('displays WORKFLOW for workflow mode', () => { @@ -163,7 +145,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('WORKFLOW')).toBeInTheDocument() + expect(screen.getByText('APP.TYPES.WORKFLOW')).toBeInTheDocument() }) it('displays COMPLETION for completion mode', () => { @@ -178,7 +160,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('COMPLETION')).toBeInTheDocument() + expect(screen.getByText('APP.TYPES.COMPLETION')).toBeInTheDocument() }) }) @@ -214,7 +196,6 @@ describe('AppInfo', () => { />, ) - // Check that there's no element with the description class that has empty content const descriptionElements = container.querySelectorAll('.system-sm-regular.mt-\\[14px\\]') expect(descriptionElements.length).toBe(0) }) @@ -233,7 +214,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('Create from Sample')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.createFromSampleApp')).toBeInTheDocument() }) it('calls onCreate when button is clicked', () => { @@ -248,7 +229,7 @@ describe('AppInfo', () => { />, ) - fireEvent.click(screen.getByText('Create from Sample')) + fireEvent.click(screen.getByText('explore.tryApp.createFromSampleApp')) expect(mockOnCreate).toHaveBeenCalledTimes(1) }) }) @@ -267,7 +248,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('Category')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.category')).toBeInTheDocument() expect(screen.getByText('AI Assistant')).toBeInTheDocument() }) @@ -283,7 +264,7 @@ describe('AppInfo', () => { />, ) - expect(screen.queryByText('Category')).not.toBeInTheDocument() + expect(screen.queryByText('explore.tryApp.category')).not.toBeInTheDocument() }) }) @@ -307,7 +288,7 @@ describe('AppInfo', () => { />, ) - expect(screen.getByText('Requirements')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.requirements')).toBeInTheDocument() expect(screen.getByText('OpenAI GPT-4')).toBeInTheDocument() expect(screen.getByText('Google Search')).toBeInTheDocument() }) @@ -328,7 +309,7 @@ describe('AppInfo', () => { />, ) - expect(screen.queryByText('Requirements')).not.toBeInTheDocument() + expect(screen.queryByText('explore.tryApp.requirements')).not.toBeInTheDocument() }) it('renders requirement icons with correct background image', () => { diff --git a/web/app/components/explore/try-app/app-info/use-get-requirements.spec.ts b/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts similarity index 99% rename from web/app/components/explore/try-app/app-info/use-get-requirements.spec.ts rename to web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts index c8af6121d1..99f38b4310 100644 --- a/web/app/components/explore/try-app/app-info/use-get-requirements.spec.ts +++ b/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts @@ -1,7 +1,7 @@ import type { TryAppInfo } from '@/service/try-app' import { renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import useGetRequirements from './use-get-requirements' +import useGetRequirements from '../use-get-requirements' const mockUseGetTryAppFlowPreview = vi.fn() @@ -165,7 +165,6 @@ describe('useGetRequirements', () => { useGetRequirements({ appDetail, appId: 'test-app-id' }), ) - // Only model provider should be included, no disabled tools expect(result.current.requirements).toHaveLength(1) expect(result.current.requirements[0].name).toBe('openai') }) diff --git a/web/app/components/explore/try-app/app/chat.spec.tsx b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx similarity index 89% rename from web/app/components/explore/try-app/app/chat.spec.tsx rename to web/app/components/explore/try-app/app/__tests__/chat.spec.tsx index ebd430c4e8..6335678a19 100644 --- a/web/app/components/explore/try-app/app/chat.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx @@ -1,19 +1,7 @@ import type { TryAppInfo } from '@/service/try-app' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import TryApp from './chat' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record<string, string> = { - 'chat.resetChat': 'Reset Chat', - 'tryApp.tryInfo': 'This is try mode info', - } - return translations[key] || key - }, - }), -})) +import TryApp from '../chat' const mockRemoveConversationIdInfo = vi.fn() const mockHandleNewConversation = vi.fn() @@ -31,7 +19,7 @@ vi.mock('@/hooks/use-breakpoints', () => ({ }, })) -vi.mock('../../../base/chat/embedded-chatbot/theme/theme-context', () => ({ +vi.mock('../../../../base/chat/embedded-chatbot/theme/theme-context', () => ({ useThemeContext: () => ({ primaryColor: '#1890ff', }), @@ -146,7 +134,7 @@ describe('TryApp (chat.tsx)', () => { />, ) - expect(screen.getByText('This is try mode info')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tryInfo')).toBeInTheDocument() }) it('applies className prop', () => { @@ -160,7 +148,6 @@ describe('TryApp (chat.tsx)', () => { />, ) - // The component wraps with EmbeddedChatbotContext.Provider, first child is the div with className const innerDiv = container.querySelector('.custom-class') expect(innerDiv).toBeInTheDocument() }) @@ -185,7 +172,6 @@ describe('TryApp (chat.tsx)', () => { />, ) - // Reset button should not be present expect(screen.queryByRole('button')).not.toBeInTheDocument() }) @@ -207,7 +193,6 @@ describe('TryApp (chat.tsx)', () => { />, ) - // Should have a button (the reset button) expect(screen.getByRole('button')).toBeInTheDocument() }) @@ -313,14 +298,12 @@ describe('TryApp (chat.tsx)', () => { />, ) - // Find and click the hide button on the alert - const alertElement = screen.getByText('This is try mode info').closest('[class*="alert"]')?.parentElement + const alertElement = screen.getByText('explore.tryApp.tryInfo').closest('[class*="alert"]')?.parentElement const hideButton = alertElement?.querySelector('button, [role="button"], svg') if (hideButton) { fireEvent.click(hideButton) - // After hiding, the alert should not be visible - expect(screen.queryByText('This is try mode info')).not.toBeInTheDocument() + expect(screen.queryByText('explore.tryApp.tryInfo')).not.toBeInTheDocument() } }) }) diff --git a/web/app/components/explore/try-app/app/index.spec.tsx b/web/app/components/explore/try-app/app/__tests__/index.spec.tsx similarity index 96% rename from web/app/components/explore/try-app/app/index.spec.tsx rename to web/app/components/explore/try-app/app/__tests__/index.spec.tsx index 927365a648..1c244e547d 100644 --- a/web/app/components/explore/try-app/app/index.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/index.spec.tsx @@ -1,19 +1,13 @@ import type { TryAppInfo } from '@/service/try-app' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import TryApp from './index' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import TryApp from '../index' vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) -vi.mock('./chat', () => ({ +vi.mock('../chat', () => ({ default: ({ appId, appDetail, className }: { appId: string, appDetail: TryAppInfo, className: string }) => ( <div data-testid="chat-component" data-app-id={appId} data-mode={appDetail.mode} className={className}> Chat Component @@ -21,7 +15,7 @@ vi.mock('./chat', () => ({ ), })) -vi.mock('./text-generation', () => ({ +vi.mock('../text-generation', () => ({ default: ({ appId, className, diff --git a/web/app/components/explore/try-app/app/text-generation.spec.tsx b/web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx similarity index 92% rename from web/app/components/explore/try-app/app/text-generation.spec.tsx rename to web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx index cbeafc5132..ddc3eb72a8 100644 --- a/web/app/components/explore/try-app/app/text-generation.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx @@ -1,18 +1,7 @@ import type { AppData } from '@/models/share' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import TextGeneration from './text-generation' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record<string, string> = { - 'tryApp.tryInfo': 'This is a try app notice', - } - return translations[key] || key - }, - }), -})) +import TextGeneration from '../text-generation' const mockUpdateAppInfo = vi.fn() const mockUpdateAppParams = vi.fn() @@ -156,7 +145,6 @@ describe('TextGeneration', () => { ) await waitFor(() => { - // Multiple elements may have the title (header and RunOnce mock) const titles = screen.getAllByText('Test App Title') expect(titles.length).toBeGreaterThan(0) }) @@ -275,7 +263,6 @@ describe('TextGeneration', () => { fireEvent.click(screen.getByTestId('send-button')) - // The send should work without errors expect(screen.getByTestId('result-component')).toBeInTheDocument() }) }) @@ -298,7 +285,7 @@ describe('TextGeneration', () => { fireEvent.click(screen.getByTestId('complete-button')) await waitFor(() => { - expect(screen.getByText('This is a try app notice')).toBeInTheDocument() + expect(screen.getByText('explore.tryApp.tryInfo')).toBeInTheDocument() }) }) }) @@ -384,7 +371,6 @@ describe('TextGeneration', () => { fireEvent.click(screen.getByTestId('run-start-button')) - // Result panel should remain visible expect(screen.getByTestId('result-component')).toBeInTheDocument() }) }) @@ -404,10 +390,8 @@ describe('TextGeneration', () => { expect(screen.getByTestId('inputs-change-button')).toBeInTheDocument() }) - // Trigger input change which should call setInputs callback fireEvent.click(screen.getByTestId('inputs-change-button')) - // The component should handle the input change without errors expect(screen.getByTestId('run-once')).toBeInTheDocument() }) }) @@ -425,7 +409,6 @@ describe('TextGeneration', () => { ) await waitFor(() => { - // Mobile toggle panel should be rendered const togglePanel = container.querySelector('.cursor-grab') expect(togglePanel).toBeInTheDocument() }) @@ -447,13 +430,11 @@ describe('TextGeneration', () => { expect(togglePanel).toBeInTheDocument() }) - // Click to show result panel const toggleParent = container.querySelector('.cursor-grab')?.parentElement if (toggleParent) { fireEvent.click(toggleParent) } - // Click again to hide result panel await waitFor(() => { const newToggleParent = container.querySelector('.cursor-grab')?.parentElement if (newToggleParent) { @@ -461,7 +442,6 @@ describe('TextGeneration', () => { } }) - // Component should handle both show and hide without errors expect(screen.getByTestId('result-component')).toBeInTheDocument() }) }) diff --git a/web/app/components/explore/try-app/preview/basic-app-preview.spec.tsx b/web/app/components/explore/try-app/preview/__tests__/basic-app-preview.spec.tsx similarity index 98% rename from web/app/components/explore/try-app/preview/basic-app-preview.spec.tsx rename to web/app/components/explore/try-app/preview/__tests__/basic-app-preview.spec.tsx index bf86d3f02f..1cd7b7c281 100644 --- a/web/app/components/explore/try-app/preview/basic-app-preview.spec.tsx +++ b/web/app/components/explore/try-app/preview/__tests__/basic-app-preview.spec.tsx @@ -1,12 +1,6 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import BasicAppPreview from './basic-app-preview' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import BasicAppPreview from '../basic-app-preview' const mockUseGetTryAppInfo = vi.fn() const mockUseAllToolProviders = vi.fn() @@ -22,7 +16,7 @@ vi.mock('@/service/use-tools', () => ({ useAllToolProviders: () => mockUseAllToolProviders(), })) -vi.mock('../../../header/account-setting/model-provider-page/hooks', () => ({ +vi.mock('../../../../header/account-setting/model-provider-page/hooks', () => ({ useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) => mockUseTextGenerationCurrentProviderAndModelAndModelList(...args), })) @@ -518,7 +512,6 @@ describe('BasicAppPreview', () => { render(<BasicAppPreview appId="test-app-id" />) - // Should still render (with default model config) await waitFor(() => { expect(mockUseGetTryAppDataSets).toHaveBeenCalled() }) diff --git a/web/app/components/explore/try-app/preview/flow-app-preview.spec.tsx b/web/app/components/explore/try-app/preview/__tests__/flow-app-preview.spec.tsx similarity index 99% rename from web/app/components/explore/try-app/preview/flow-app-preview.spec.tsx rename to web/app/components/explore/try-app/preview/__tests__/flow-app-preview.spec.tsx index c4e8175b82..22410a1e81 100644 --- a/web/app/components/explore/try-app/preview/flow-app-preview.spec.tsx +++ b/web/app/components/explore/try-app/preview/__tests__/flow-app-preview.spec.tsx @@ -1,6 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import FlowAppPreview from './flow-app-preview' +import FlowAppPreview from '../flow-app-preview' const mockUseGetTryAppFlowPreview = vi.fn() diff --git a/web/app/components/explore/try-app/preview/index.spec.tsx b/web/app/components/explore/try-app/preview/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/explore/try-app/preview/index.spec.tsx rename to web/app/components/explore/try-app/preview/__tests__/index.spec.tsx index 022511efac..701253a302 100644 --- a/web/app/components/explore/try-app/preview/index.spec.tsx +++ b/web/app/components/explore/try-app/preview/__tests__/index.spec.tsx @@ -1,9 +1,9 @@ import type { TryAppInfo } from '@/service/try-app' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import Preview from './index' +import Preview from '../index' -vi.mock('./basic-app-preview', () => ({ +vi.mock('../basic-app-preview', () => ({ default: ({ appId }: { appId: string }) => ( <div data-testid="basic-app-preview" data-app-id={appId}> BasicAppPreview @@ -11,7 +11,7 @@ vi.mock('./basic-app-preview', () => ({ ), })) -vi.mock('./flow-app-preview', () => ({ +vi.mock('../flow-app-preview', () => ({ default: ({ appId, className }: { appId: string, className?: string }) => ( <div data-testid="flow-app-preview" data-app-id={appId} className={className}> FlowAppPreview From f233e2036fa2943a10503f036922a1d705a04182 Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:59:59 -0800 Subject: [PATCH 09/18] fix: metadata batch edit silently fails due to split transactions and swallowed exceptions (#32041) --- api/services/metadata_service.py | 6 +- .../services/test_metadata_service.py | 18 ++-- .../services/test_metadata_partial_update.py | 34 ++++++++ .../use-batch-edit-document-metadata.spec.ts | 86 +++++++++++++++++++ .../hooks/use-batch-edit-document-metadata.ts | 18 ++-- 5 files changed, 142 insertions(+), 20 deletions(-) diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index 3329ac349c..859fc1902b 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -220,8 +220,8 @@ class MetadataService: doc_metadata[BuiltInField.source] = MetadataDataSource[document.data_source_type] document.doc_metadata = doc_metadata db.session.add(document) - db.session.commit() - # deal metadata binding + + # deal metadata binding (in the same transaction as the doc_metadata update) if not operation.partial_update: db.session.query(DatasetMetadataBinding).filter_by(document_id=operation.document_id).delete() @@ -247,7 +247,9 @@ class MetadataService: db.session.add(dataset_metadata_binding) db.session.commit() except Exception: + db.session.rollback() logger.exception("Update documents metadata failed") + raise finally: redis_client.delete(lock_key) diff --git a/api/tests/test_containers_integration_tests/services/test_metadata_service.py b/api/tests/test_containers_integration_tests/services/test_metadata_service.py index c8ced3f3a5..e04725627b 100644 --- a/api/tests/test_containers_integration_tests/services/test_metadata_service.py +++ b/api/tests/test_containers_integration_tests/services/test_metadata_service.py @@ -914,9 +914,6 @@ class TestMetadataService: metadata_args = MetadataArgs(type="string", name="test_metadata") metadata = MetadataService.create_metadata(dataset.id, metadata_args) - # Mock DocumentService.get_document to return None (document not found) - mock_external_service_dependencies["document_service"].get_document.return_value = None - # Create metadata operation data from services.entities.knowledge_entities.knowledge_entities import ( DocumentMetadataOperation, @@ -926,16 +923,17 @@ class TestMetadataService: metadata_detail = MetadataDetail(id=metadata.id, name=metadata.name, value="test_value") - operation = DocumentMetadataOperation(document_id="non-existent-document-id", metadata_list=[metadata_detail]) + # Use a valid UUID format that does not exist in the database + operation = DocumentMetadataOperation( + document_id="00000000-0000-0000-0000-000000000000", metadata_list=[metadata_detail] + ) operation_data = MetadataOperationData(operation_data=[operation]) - # Act: Execute the method under test - # The method should handle the error gracefully and continue - MetadataService.update_documents_metadata(dataset, operation_data) - - # Assert: Verify the method completes without raising exceptions - # The main functionality (error handling) is verified + # Act & Assert: The method should raise ValueError("Document not found.") + # because the exception is now re-raised after rollback + with pytest.raises(ValueError, match="Document not found"): + MetadataService.update_documents_metadata(dataset, operation_data) def test_knowledge_base_metadata_lock_check_dataset_id( self, db_session_with_containers, mock_external_service_dependencies diff --git a/api/tests/unit_tests/services/test_metadata_partial_update.py b/api/tests/unit_tests/services/test_metadata_partial_update.py index 00162c10e4..60252784bc 100644 --- a/api/tests/unit_tests/services/test_metadata_partial_update.py +++ b/api/tests/unit_tests/services/test_metadata_partial_update.py @@ -1,6 +1,8 @@ import unittest from unittest.mock import MagicMock, patch +import pytest + from models.dataset import Dataset, Document from services.entities.knowledge_entities.knowledge_entities import ( DocumentMetadataOperation, @@ -148,6 +150,38 @@ class TestMetadataPartialUpdate(unittest.TestCase): # If it were added, there would be 2 calls. If skipped, 1 call. assert mock_db.session.add.call_count == 1 + @patch("services.metadata_service.db") + @patch("services.metadata_service.DocumentService") + @patch("services.metadata_service.current_account_with_tenant") + @patch("services.metadata_service.redis_client") + def test_rollback_called_on_commit_failure(self, mock_redis, mock_current_account, mock_document_service, mock_db): + """When db.session.commit() raises, rollback must be called and the exception must propagate.""" + # Setup mocks + mock_redis.get.return_value = None + mock_document_service.get_document.return_value = self.document + mock_current_account.return_value = (MagicMock(id="user_id"), "tenant_id") + mock_db.session.query.return_value.filter_by.return_value.first.return_value = None + + # Make commit raise an exception + mock_db.session.commit.side_effect = RuntimeError("database connection lost") + + operation = DocumentMetadataOperation( + document_id="doc_id", + metadata_list=[MetadataDetail(id="meta_id", name="key", value="value")], + partial_update=True, + ) + metadata_args = MetadataOperationData(operation_data=[operation]) + + # Act & Assert: the exception must propagate + with pytest.raises(RuntimeError, match="database connection lost"): + MetadataService.update_documents_metadata(self.dataset, metadata_args) + + # Verify rollback was called + mock_db.session.rollback.assert_called_once() + + # Verify the lock key was cleaned up despite the failure + mock_redis.delete.assert_called_with("document_metadata_lock_doc_id") + if __name__ == "__main__": unittest.main() diff --git a/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts index bdcd2004d7..30ff2aa2aa 100644 --- a/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts @@ -22,6 +22,7 @@ type MetadataItemWithEdit = { type: DataType value: string | number | null isMultipleValue?: boolean + isUpdated?: boolean updateType?: UpdateType } @@ -615,6 +616,91 @@ describe('useBatchEditDocumentMetadata', () => { }) }) + describe('toCleanMetadataItem sanitization', () => { + it('should strip extra fields (isMultipleValue, updateType, isUpdated) from metadata items sent to backend', async () => { + const docListSingleDoc: DocListItem[] = [ + { + id: 'doc-1', + doc_metadata: [ + { id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' }, + ], + }, + ] + + const { result } = renderHook(() => + useBatchEditDocumentMetadata({ + ...defaultProps, + docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + }), + ) + + const editedList: MetadataItemWithEdit[] = [ + { + id: '1', + name: 'field_one', + type: DataType.string, + value: 'New Value', + isMultipleValue: true, + isUpdated: true, + updateType: UpdateType.changeValue, + }, + ] + + await act(async () => { + await result.current.handleSave(editedList, [], false) + }) + + const callArgs = mockMutateAsync.mock.calls[0][0] + const sentItem = callArgs.metadata_list[0].metadata_list[0] + + // Only id, name, type, value should be present + expect(Object.keys(sentItem).sort()).toEqual(['id', 'name', 'type', 'value'].sort()) + expect(sentItem).not.toHaveProperty('isMultipleValue') + expect(sentItem).not.toHaveProperty('updateType') + expect(sentItem).not.toHaveProperty('isUpdated') + }) + + it('should coerce undefined value to null in metadata items sent to backend', async () => { + const docListSingleDoc: DocListItem[] = [ + { + id: 'doc-1', + doc_metadata: [ + { id: '1', name: 'field_one', type: DataType.string, value: 'Value' }, + ], + }, + ] + + const { result } = renderHook(() => + useBatchEditDocumentMetadata({ + ...defaultProps, + docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'], + }), + ) + + // Pass an item with value explicitly set to undefined (via cast) + const editedList: MetadataItemWithEdit[] = [ + { + id: '1', + name: 'field_one', + type: DataType.string, + value: undefined as unknown as null, + updateType: UpdateType.changeValue, + }, + ] + + await act(async () => { + await result.current.handleSave(editedList, [], false) + }) + + const callArgs = mockMutateAsync.mock.calls[0][0] + const sentItem = callArgs.metadata_list[0].metadata_list[0] + + // value should be null, not undefined + expect(sentItem.value).toBeNull() + expect(sentItem.value).not.toBeUndefined() + }) + }) + describe('Edge Cases', () => { it('should handle empty docList', () => { const { result } = renderHook(() => diff --git a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts b/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts index f3243ca6b6..84e6496859 100644 --- a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts +++ b/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts @@ -71,6 +71,13 @@ const useBatchEditDocumentMetadata = ({ return res }, [metaDataList]) + const toCleanMetadataItem = (item: MetadataItemWithValue | MetadataItemWithEdit | MetadataItemInBatchEdit): MetadataItemWithValue => ({ + id: item.id, + name: item.name, + type: item.type, + value: item.value ?? null, + }) + const formateToBackendList = (editedList: MetadataItemWithEdit[], addedList: MetadataItemInBatchEdit[], isApplyToAllSelectDocument: boolean) => { const updatedList = editedList.filter((editedItem) => { return editedItem.updateType === UpdateType.changeValue @@ -92,24 +99,19 @@ const useBatchEditDocumentMetadata = ({ .filter((item) => { return !removedList.find(removedItem => removedItem.id === item.id) }) - .map(item => ({ - id: item.id, - name: item.name, - type: item.type, - value: item.value, - })) + .map(toCleanMetadataItem) if (isApplyToAllSelectDocument) { // add missing metadata item updatedList.forEach((editedItem) => { if (!newMetadataList.find(i => i.id === editedItem.id) && !editedItem.isMultipleValue) - newMetadataList.push(editedItem) + newMetadataList.push(toCleanMetadataItem(editedItem)) }) } newMetadataList = newMetadataList.map((item) => { const editedItem = updatedList.find(i => i.id === item.id) if (editedItem) - return editedItem + return toCleanMetadataItem(editedItem) return item }) From 8fd3eeb76022cb938bba5917ca922a7c72075ccc Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:23:01 +0800 Subject: [PATCH 10/18] fix: can not upload file in single run (#32276) --- web/app/components/base/file-uploader/store.tsx | 14 +------------- .../_base/components/before-run-form/form-item.tsx | 8 ++++---- web/eslint-suppressions.json | 8 -------- 3 files changed, 5 insertions(+), 25 deletions(-) diff --git a/web/app/components/base/file-uploader/store.tsx b/web/app/components/base/file-uploader/store.tsx index 24015df5cf..b281a9de8f 100644 --- a/web/app/components/base/file-uploader/store.tsx +++ b/web/app/components/base/file-uploader/store.tsx @@ -1,11 +1,9 @@ import type { FileEntity, } from './types' -import { isEqual } from 'es-toolkit/predicate' import { createContext, useContext, - useEffect, useRef, } from 'react' import { @@ -57,20 +55,10 @@ export const FileContextProvider = ({ onChange, }: FileProviderProps) => { const storeRef = useRef<FileStore | undefined>(undefined) + if (!storeRef.current) storeRef.current = createFileStore(value, onChange) - useEffect(() => { - if (!storeRef.current) - return - if (isEqual(value, storeRef.current.getState().files)) - return - - storeRef.current.setState({ - files: value ? [...value] : [], - }) - }, [value]) - return ( <FileContext.Provider value={storeRef.current}> {children} diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx index d66d47cc1f..e45c9dbd95 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx @@ -108,7 +108,7 @@ const FormItem: FC<Props> = ({ const isIteratorItemFile = isIterator && payload.isFileItem const singleFileValue = useMemo(() => { if (payload.variable === '#files#') - return value?.[0] || [] + return value || [] return value ? [value] : [] }, [payload.variable, value]) @@ -124,19 +124,19 @@ const FormItem: FC<Props> = ({ return ( <div className={cn(className)}> {!isArrayLikeType && !isBooleanType && ( - <div className="system-sm-semibold mb-1 flex h-6 items-center gap-1 text-text-secondary"> + <div className="mb-1 flex h-6 items-center gap-1 text-text-secondary system-sm-semibold"> <div className="truncate"> {typeof payload.label === 'object' ? nodeKey : payload.label} </div> {payload.hide === true ? ( - <span className="system-xs-regular text-text-tertiary"> + <span className="text-text-tertiary system-xs-regular"> {t('panel.optional_and_hidden', { ns: 'workflow' })} </span> ) : ( !payload.required && ( - <span className="system-xs-regular text-text-tertiary"> + <span className="text-text-tertiary system-xs-regular"> {t('panel.optional', { ns: 'workflow' })} </span> ) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 90571e4947..f55a49c564 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -4102,11 +4102,6 @@ "count": 1 } }, - "app/components/explore/app-card/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/explore/app-card/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -6201,9 +6196,6 @@ } }, "app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 3 - }, "ts/no-explicit-any": { "count": 11 } From 0118b45cff36fb1c61d0b1833467bb7e85d50d9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 04:47:19 +0900 Subject: [PATCH 11/18] chore(deps): bump pillow from 12.0.0 to 12.1.1 in /api (#32250) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 62 ++++++++++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index 30ff1f8df1..03622e0ce6 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4473,39 +4473,39 @@ wheels = [ [[package]] name = "pillow" -version = "12.0.0" +version = "12.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, - { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, - { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, - { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, - { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, - { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, - { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, - { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, - { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, - { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, - { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] [[package]] From c0ffb6db2a12b27b3662ae76648fc318a2f937b0 Mon Sep 17 00:00:00 2001 From: Bowen Liang <bowenliang@apache.org> Date: Fri, 13 Feb 2026 09:48:27 +0800 Subject: [PATCH 12/18] feat: support config max size of plugin generated files (#30887) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/configs/feature/__init__.py | 5 +++++ api/core/plugin/impl/tool.py | 4 +++- api/pyproject.toml | 2 +- api/uv.lock | 8 ++++---- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 3fe9031dff..d37cff63e9 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -265,6 +265,11 @@ class PluginConfig(BaseSettings): default=60 * 60, ) + PLUGIN_MAX_FILE_SIZE: PositiveInt = Field( + description="Maximum allowed size (bytes) for plugin-generated files", + default=50 * 1024 * 1024, + ) + class MarketplaceConfig(BaseSettings): """ diff --git a/api/core/plugin/impl/tool.py b/api/core/plugin/impl/tool.py index 6fa5136b42..cc38ecfce2 100644 --- a/api/core/plugin/impl/tool.py +++ b/api/core/plugin/impl/tool.py @@ -3,6 +3,8 @@ from typing import Any from pydantic import BaseModel +from configs import dify_config + # from core.plugin.entities.plugin import GenericProviderID, ToolProviderID from core.plugin.entities.plugin_daemon import CredentialType, PluginBasicBooleanResponse, PluginToolProviderEntity from core.plugin.impl.base import BasePluginClient @@ -122,7 +124,7 @@ class PluginToolManager(BasePluginClient): }, ) - return merge_blob_chunks(response) + return merge_blob_chunks(response, max_file_size=dify_config.PLUGIN_MAX_FILE_SIZE) def validate_provider_credentials( self, tenant_id: str, user_id: str, provider: str, credentials: dict[str, Any] diff --git a/api/pyproject.toml b/api/pyproject.toml index a3ea683bda..530b0c0da3 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "pycryptodome==3.23.0", "pydantic~=2.11.4", "pydantic-extra-types~=2.10.3", - "pydantic-settings~=2.11.0", + "pydantic-settings~=2.12.0", "pyjwt~=2.10.1", "pypdfium2==5.2.0", "python-docx~=1.1.0", diff --git a/api/uv.lock b/api/uv.lock index 03622e0ce6..afad10dc94 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1635,7 +1635,7 @@ requires-dist = [ { name = "pycryptodome", specifier = "==3.23.0" }, { name = "pydantic", specifier = "~=2.11.4" }, { name = "pydantic-extra-types", specifier = "~=2.10.3" }, - { name = "pydantic-settings", specifier = "~=2.11.0" }, + { name = "pydantic-settings", specifier = "~=2.12.0" }, { name = "pyjwt", specifier = "~=2.10.1" }, { name = "pypdfium2", specifier = "==5.2.0" }, { name = "python-docx", specifier = "~=1.1.0" }, @@ -4900,16 +4900,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.11.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] [[package]] From 16df9851a22d3cda0f8a4df630e964f2aba29c95 Mon Sep 17 00:00:00 2001 From: Conner Mo <conner.mo@gmail.com> Date: Fri, 13 Feb 2026 09:48:55 +0800 Subject: [PATCH 13/18] feat(api): optimize OceanBase vector store performance and configurability (#32263) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../middleware/vdb/oceanbase_config.py | 42 +++ .../vdb/oceanbase/oceanbase_vector.py | 127 +++++++-- .../vdb/oceanbase/bench_oceanbase.py | 241 ++++++++++++++++++ .../vdb/oceanbase/test_oceanbase.py | 1 + 4 files changed, 389 insertions(+), 22 deletions(-) create mode 100644 api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py diff --git a/api/configs/middleware/vdb/oceanbase_config.py b/api/configs/middleware/vdb/oceanbase_config.py index 7c9376f86b..27ec99e56a 100644 --- a/api/configs/middleware/vdb/oceanbase_config.py +++ b/api/configs/middleware/vdb/oceanbase_config.py @@ -1,3 +1,5 @@ +from typing import Literal + from pydantic import Field, PositiveInt from pydantic_settings import BaseSettings @@ -49,3 +51,43 @@ class OceanBaseVectorConfig(BaseSettings): ), default="ik", ) + + OCEANBASE_VECTOR_BATCH_SIZE: PositiveInt = Field( + description="Number of documents to insert per batch", + default=100, + ) + + OCEANBASE_VECTOR_METRIC_TYPE: Literal["l2", "cosine", "inner_product"] = Field( + description="Distance metric type for vector index: l2, cosine, or inner_product", + default="l2", + ) + + OCEANBASE_HNSW_M: PositiveInt = Field( + description="HNSW M parameter (max number of connections per node)", + default=16, + ) + + OCEANBASE_HNSW_EF_CONSTRUCTION: PositiveInt = Field( + description="HNSW efConstruction parameter (index build-time search width)", + default=256, + ) + + OCEANBASE_HNSW_EF_SEARCH: int = Field( + description="HNSW efSearch parameter (query-time search width, -1 uses server default)", + default=-1, + ) + + OCEANBASE_VECTOR_POOL_SIZE: PositiveInt = Field( + description="SQLAlchemy connection pool size", + default=5, + ) + + OCEANBASE_VECTOR_MAX_OVERFLOW: int = Field( + description="SQLAlchemy connection pool max overflow connections", + default=10, + ) + + OCEANBASE_HNSW_REFRESH_THRESHOLD: int = Field( + description="Minimum number of inserted documents to trigger an automatic HNSW index refresh (0 to disable)", + default=1000, + ) diff --git a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py b/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py index dc3b70140b..86c1e65f47 100644 --- a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py +++ b/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py @@ -1,12 +1,13 @@ import json import logging -import math -from typing import Any +import re +from typing import Any, Literal from pydantic import BaseModel, model_validator -from pyobvector import VECTOR, ObVecClient, l2_distance # type: ignore +from pyobvector import VECTOR, ObVecClient, cosine_distance, inner_product, l2_distance # type: ignore from sqlalchemy import JSON, Column, String from sqlalchemy.dialects.mysql import LONGTEXT +from sqlalchemy.exc import SQLAlchemyError from configs import dify_config from core.rag.datasource.vdb.vector_base import BaseVector @@ -19,10 +20,14 @@ from models.dataset import Dataset logger = logging.getLogger(__name__) -DEFAULT_OCEANBASE_HNSW_BUILD_PARAM = {"M": 16, "efConstruction": 256} -DEFAULT_OCEANBASE_HNSW_SEARCH_PARAM = {"efSearch": 64} OCEANBASE_SUPPORTED_VECTOR_INDEX_TYPE = "HNSW" -DEFAULT_OCEANBASE_VECTOR_METRIC_TYPE = "l2" +_VALID_TABLE_NAME_RE = re.compile(r"^[a-zA-Z0-9_]+$") + +_DISTANCE_FUNC_MAP = { + "l2": l2_distance, + "cosine": cosine_distance, + "inner_product": inner_product, +} class OceanBaseVectorConfig(BaseModel): @@ -32,6 +37,14 @@ class OceanBaseVectorConfig(BaseModel): password: str database: str enable_hybrid_search: bool = False + batch_size: int = 100 + metric_type: Literal["l2", "cosine", "inner_product"] = "l2" + hnsw_m: int = 16 + hnsw_ef_construction: int = 256 + hnsw_ef_search: int = -1 + pool_size: int = 5 + max_overflow: int = 10 + hnsw_refresh_threshold: int = 1000 @model_validator(mode="before") @classmethod @@ -49,14 +62,23 @@ class OceanBaseVectorConfig(BaseModel): class OceanBaseVector(BaseVector): def __init__(self, collection_name: str, config: OceanBaseVectorConfig): + if not _VALID_TABLE_NAME_RE.match(collection_name): + raise ValueError( + f"Invalid collection name '{collection_name}': " + "only alphanumeric characters and underscores are allowed." + ) super().__init__(collection_name) self._config = config - self._hnsw_ef_search = -1 + self._hnsw_ef_search = self._config.hnsw_ef_search self._client = ObVecClient( uri=f"{self._config.host}:{self._config.port}", user=self._config.user, password=self._config.password, db_name=self._config.database, + pool_size=self._config.pool_size, + max_overflow=self._config.max_overflow, + pool_recycle=3600, + pool_pre_ping=True, ) self._fields: list[str] = [] # List of fields in the collection if self._client.check_table_exists(collection_name): @@ -136,8 +158,8 @@ class OceanBaseVector(BaseVector): field_name="vector", index_type=OCEANBASE_SUPPORTED_VECTOR_INDEX_TYPE, index_name="vector_index", - metric_type=DEFAULT_OCEANBASE_VECTOR_METRIC_TYPE, - params=DEFAULT_OCEANBASE_HNSW_BUILD_PARAM, + metric_type=self._config.metric_type, + params={"M": self._config.hnsw_m, "efConstruction": self._config.hnsw_ef_construction}, ) self._client.create_table_with_index_params( @@ -178,6 +200,17 @@ class OceanBaseVector(BaseVector): else: logger.debug("DEBUG: Hybrid search is NOT enabled for '%s'", self._collection_name) + try: + self._client.perform_raw_text_sql( + f"CREATE INDEX IF NOT EXISTS idx_metadata_doc_id ON `{self._collection_name}` " + f"((CAST(metadata->>'$.document_id' AS CHAR(64))))" + ) + except SQLAlchemyError: + logger.warning( + "Failed to create metadata functional index on '%s'; metadata queries may be slow without it.", + self._collection_name, + ) + self._client.refresh_metadata([self._collection_name]) self._load_collection_fields() redis_client.set(collection_exist_cache_key, 1, ex=3600) @@ -205,24 +238,49 @@ class OceanBaseVector(BaseVector): def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs): ids = self._get_uuids(documents) - for id, doc, emb in zip(ids, documents, embeddings): + batch_size = self._config.batch_size + total = len(documents) + + all_data = [ + { + "id": doc_id, + "vector": emb, + "text": doc.page_content, + "metadata": doc.metadata, + } + for doc_id, doc, emb in zip(ids, documents, embeddings) + ] + + for start in range(0, total, batch_size): + batch = all_data[start : start + batch_size] try: self._client.insert( table_name=self._collection_name, - data={ - "id": id, - "vector": emb, - "text": doc.page_content, - "metadata": doc.metadata, - }, + data=batch, ) except Exception as e: logger.exception( - "Failed to insert document with id '%s' in collection '%s'", - id, + "Failed to insert batch [%d:%d] into collection '%s'", + start, + start + len(batch), + self._collection_name, + ) + raise Exception( + f"Failed to insert batch [{start}:{start + len(batch)}] into collection '{self._collection_name}'" + ) from e + + if self._config.hnsw_refresh_threshold > 0 and total >= self._config.hnsw_refresh_threshold: + try: + self._client.refresh_index( + table_name=self._collection_name, + index_name="vector_index", + ) + except SQLAlchemyError: + logger.warning( + "Failed to refresh HNSW index after inserting %d documents into '%s'", + total, self._collection_name, ) - raise Exception(f"Failed to insert document with id '{id}'") from e def text_exists(self, id: str) -> bool: try: @@ -412,7 +470,7 @@ class OceanBaseVector(BaseVector): vec_column_name="vector", vec_data=query_vector, topk=topk, - distance_func=l2_distance, + distance_func=self._get_distance_func(), output_column_names=["text", "metadata"], with_dist=True, where_clause=_where_clause, @@ -424,14 +482,31 @@ class OceanBaseVector(BaseVector): ) raise Exception(f"Vector search failed for collection '{self._collection_name}'") from e - # Convert distance to score and prepare results for processing results = [] for _text, metadata_str, distance in cur: - score = 1 - distance / math.sqrt(2) + score = self._distance_to_score(distance) results.append((_text, metadata_str, score)) return self._process_search_results(results, score_threshold=score_threshold) + def _get_distance_func(self): + func = _DISTANCE_FUNC_MAP.get(self._config.metric_type) + if func is None: + raise ValueError( + f"Unsupported metric_type '{self._config.metric_type}'. Supported: {', '.join(_DISTANCE_FUNC_MAP)}" + ) + return func + + def _distance_to_score(self, distance: float) -> float: + metric = self._config.metric_type + if metric == "l2": + return 1.0 / (1.0 + distance) + elif metric == "cosine": + return 1.0 - distance + elif metric == "inner_product": + return -distance + raise ValueError(f"Unsupported metric_type '{metric}'") + def delete(self): try: self._client.drop_table_if_exist(self._collection_name) @@ -464,5 +539,13 @@ class OceanBaseVectorFactory(AbstractVectorFactory): password=(dify_config.OCEANBASE_VECTOR_PASSWORD or ""), database=dify_config.OCEANBASE_VECTOR_DATABASE or "", enable_hybrid_search=dify_config.OCEANBASE_ENABLE_HYBRID_SEARCH or False, + batch_size=dify_config.OCEANBASE_VECTOR_BATCH_SIZE, + metric_type=dify_config.OCEANBASE_VECTOR_METRIC_TYPE, + hnsw_m=dify_config.OCEANBASE_HNSW_M, + hnsw_ef_construction=dify_config.OCEANBASE_HNSW_EF_CONSTRUCTION, + hnsw_ef_search=dify_config.OCEANBASE_HNSW_EF_SEARCH, + pool_size=dify_config.OCEANBASE_VECTOR_POOL_SIZE, + max_overflow=dify_config.OCEANBASE_VECTOR_MAX_OVERFLOW, + hnsw_refresh_threshold=dify_config.OCEANBASE_HNSW_REFRESH_THRESHOLD, ), ) diff --git a/api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py b/api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py new file mode 100644 index 0000000000..8b57be08c5 --- /dev/null +++ b/api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py @@ -0,0 +1,241 @@ +""" +Benchmark: OceanBase vector store — old (single-row) vs new (batch) insertion, +metadata query with/without functional index, and vector search across metrics. + +Usage: + uv run --project api python -m tests.integration_tests.vdb.oceanbase.bench_oceanbase +""" + +import json +import random +import statistics +import time +import uuid + +from pyobvector import VECTOR, ObVecClient, cosine_distance, inner_product, l2_distance +from sqlalchemy import JSON, Column, String, text +from sqlalchemy.dialects.mysql import LONGTEXT + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +HOST = "127.0.0.1" +PORT = 2881 +USER = "root@test" +PASSWORD = "difyai123456" +DATABASE = "test" + +VEC_DIM = 1536 +HNSW_BUILD = {"M": 16, "efConstruction": 256} +DISTANCE_FUNCS = {"l2": l2_distance, "cosine": cosine_distance, "inner_product": inner_product} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _make_client(**extra): + return ObVecClient( + uri=f"{HOST}:{PORT}", + user=USER, + password=PASSWORD, + db_name=DATABASE, + **extra, + ) + + +def _rand_vec(): + return [random.uniform(-1, 1) for _ in range(VEC_DIM)] # noqa: S311 + + +def _drop(client, table): + client.drop_table_if_exist(table) + + +def _create_table(client, table, metric="l2"): + cols = [ + Column("id", String(36), primary_key=True, autoincrement=False), + Column("vector", VECTOR(VEC_DIM)), + Column("text", LONGTEXT), + Column("metadata", JSON), + ] + vidx = client.prepare_index_params() + vidx.add_index( + field_name="vector", + index_type="HNSW", + index_name="vector_index", + metric_type=metric, + params=HNSW_BUILD, + ) + client.create_table_with_index_params(table_name=table, columns=cols, vidxs=vidx) + client.refresh_metadata([table]) + + +def _gen_rows(n): + doc_id = str(uuid.uuid4()) + rows = [] + for _ in range(n): + rows.append( + { + "id": str(uuid.uuid4()), + "vector": _rand_vec(), + "text": f"benchmark text {uuid.uuid4().hex[:12]}", + "metadata": json.dumps({"document_id": doc_id, "dataset_id": str(uuid.uuid4())}), + } + ) + return rows, doc_id + + +# --------------------------------------------------------------------------- +# Benchmark: Insertion +# --------------------------------------------------------------------------- +def bench_insert_single(client, table, rows): + """Old approach: one INSERT per row.""" + t0 = time.perf_counter() + for row in rows: + client.insert(table_name=table, data=row) + return time.perf_counter() - t0 + + +def bench_insert_batch(client, table, rows, batch_size=100): + """New approach: batch INSERT.""" + t0 = time.perf_counter() + for start in range(0, len(rows), batch_size): + batch = rows[start : start + batch_size] + client.insert(table_name=table, data=batch) + return time.perf_counter() - t0 + + +# --------------------------------------------------------------------------- +# Benchmark: Metadata query +# --------------------------------------------------------------------------- +def bench_metadata_query(client, table, doc_id, with_index=False): + """Query by metadata->>'$.document_id' with/without functional index.""" + if with_index: + try: + client.perform_raw_text_sql(f"CREATE INDEX idx_metadata_doc_id ON `{table}` ((metadata->>'$.document_id'))") + except Exception: + pass # already exists + + sql = text(f"SELECT id FROM `{table}` WHERE metadata->>'$.document_id' = :val") + times = [] + with client.engine.connect() as conn: + for _ in range(10): + t0 = time.perf_counter() + result = conn.execute(sql, {"val": doc_id}) + _ = result.fetchall() + times.append(time.perf_counter() - t0) + return times + + +# --------------------------------------------------------------------------- +# Benchmark: Vector search +# --------------------------------------------------------------------------- +def bench_vector_search(client, table, metric, topk=10, n_queries=20): + dist_func = DISTANCE_FUNCS[metric] + times = [] + for _ in range(n_queries): + q = _rand_vec() + t0 = time.perf_counter() + cur = client.ann_search( + table_name=table, + vec_column_name="vector", + vec_data=q, + topk=topk, + distance_func=dist_func, + output_column_names=["text", "metadata"], + with_dist=True, + ) + _ = list(cur) + times.append(time.perf_counter() - t0) + return times + + +def _fmt(times): + """Format list of durations as 'mean ± stdev'.""" + m = statistics.mean(times) * 1000 + s = statistics.stdev(times) * 1000 if len(times) > 1 else 0 + return f"{m:.1f} ± {s:.1f} ms" + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main(): + client = _make_client() + client_pooled = _make_client(pool_size=5, max_overflow=10, pool_recycle=3600, pool_pre_ping=True) + + print("=" * 70) + print("OceanBase Vector Store — Performance Benchmark") + print(f" Endpoint : {HOST}:{PORT}") + print(f" Vec dim : {VEC_DIM}") + print("=" * 70) + + # ------------------------------------------------------------------ + # 1. Insertion benchmark + # ------------------------------------------------------------------ + for n_docs in [100, 500, 1000]: + rows, doc_id = _gen_rows(n_docs) + tbl_single = f"bench_single_{n_docs}" + tbl_batch = f"bench_batch_{n_docs}" + + _drop(client, tbl_single) + _drop(client, tbl_batch) + _create_table(client, tbl_single) + _create_table(client, tbl_batch) + + t_single = bench_insert_single(client, tbl_single, rows) + t_batch = bench_insert_batch(client_pooled, tbl_batch, rows, batch_size=100) + + speedup = t_single / t_batch if t_batch > 0 else float("inf") + print(f"\n[Insert {n_docs} docs]") + print(f" Single-row : {t_single:.2f}s") + print(f" Batch(100) : {t_batch:.2f}s") + print(f" Speedup : {speedup:.1f}x") + + # ------------------------------------------------------------------ + # 2. Metadata query benchmark (use the 1000-doc batch table) + # ------------------------------------------------------------------ + tbl_meta = "bench_batch_1000" + rows_1000, doc_id_1000 = _gen_rows(1000) + # The table already has 1000 rows from step 1; use that doc_id + # Re-query doc_id from one of the rows we inserted + with client.engine.connect() as conn: + res = conn.execute(text(f"SELECT metadata->>'$.document_id' FROM `{tbl_meta}` LIMIT 1")) + doc_id_1000 = res.fetchone()[0] + + print("\n[Metadata filter query — 1000 rows, by document_id]") + times_no_idx = bench_metadata_query(client, tbl_meta, doc_id_1000, with_index=False) + print(f" Without index : {_fmt(times_no_idx)}") + times_with_idx = bench_metadata_query(client, tbl_meta, doc_id_1000, with_index=True) + print(f" With index : {_fmt(times_with_idx)}") + + # ------------------------------------------------------------------ + # 3. Vector search benchmark — across metrics + # ------------------------------------------------------------------ + print("\n[Vector search — top-10, 20 queries each, on 1000 rows]") + + for metric in ["l2", "cosine", "inner_product"]: + tbl_vs = f"bench_vs_{metric}" + _drop(client_pooled, tbl_vs) + _create_table(client_pooled, tbl_vs, metric=metric) + # Insert 1000 rows + rows_vs, _ = _gen_rows(1000) + bench_insert_batch(client_pooled, tbl_vs, rows_vs, batch_size=100) + times = bench_vector_search(client_pooled, tbl_vs, metric, topk=10, n_queries=20) + print(f" {metric:15s}: {_fmt(times)}") + _drop(client_pooled, tbl_vs) + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + for n in [100, 500, 1000]: + _drop(client, f"bench_single_{n}") + _drop(client, f"bench_batch_{n}") + + print("\n" + "=" * 70) + print("Benchmark complete.") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py b/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py index 8fbbbe61b8..2db6732354 100644 --- a/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py +++ b/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py @@ -21,6 +21,7 @@ def oceanbase_vector(): database="test", password="difyai123456", enable_hybrid_search=True, + batch_size=10, ), ) From b6d506828b4d29771f43a9dbc47883a748450ffc Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 13 Feb 2026 10:27:48 +0800 Subject: [PATCH 14/18] test(web): add and enhance frontend automated tests across multiple modules (#32268) Co-authored-by: CodingOnStar <hanxujiang@dify.com> --- .../__tests__/index.spec.tsx | 73 +- .../__tests__/index.spec.tsx | 15 +- .../plan-switcher/__tests__/tab.spec.tsx | 19 +- .../progress-bar/__tests__/index.spec.tsx | 28 +- .../usage-info/__tests__/index.spec.tsx | 53 +- .../__tests__/vector-space-info.spec.tsx | 91 +- .../{ => __tests__}/index.spec.tsx | 7 +- .../{ => __tests__}/index.spec.tsx | 16 +- .../__tests__/constants.spec.ts | 41 + .../datasets/create/__tests__/icons.spec.ts | 31 + .../file-uploader/__tests__/constants.spec.ts | 33 + .../components/__tests__/list.spec.tsx | 240 ++++ .../process-documents/__tests__/form.spec.tsx | 167 +++ .../__tests__/doc-type-selector.spec.tsx | 147 +++ .../components/__tests__/field-info.spec.tsx | 116 ++ .../__tests__/metadata-field-list.spec.tsx | 149 +++ .../__tests__/use-metadata-state.spec.ts | 164 +++ .../query-input/__tests__/index.spec.tsx | 363 +++++- .../develop/__tests__/code.spec.tsx | 255 +---- .../secret-key/__tests__/input-copy.spec.tsx | 18 +- .../__tests__/secret-key-modal.spec.tsx | 3 + .../explore/__tests__/category.spec.tsx | 6 + .../explore/__tests__/index.spec.tsx | 85 +- .../explore/app-card/__tests__/index.spec.tsx | 28 + .../explore/app-list/__tests__/index.spec.tsx | 329 +++++- .../explore/try-app/__tests__/index.spec.tsx | 3 + .../actions/__tests__/app.spec.ts | 7 +- .../actions/__tests__/index.spec.ts | 9 + .../actions/__tests__/knowledge.spec.ts | 7 +- .../actions/__tests__/plugin.spec.ts | 9 + .../__tests__/direct-commands.spec.ts | 86 +- .../commands/__tests__/registry.spec.ts | 6 + .../plugins/__tests__/hooks.spec.ts | 307 +---- .../__tests__/use-fold-anim-into.spec.ts | 171 +++ .../__tests__/ready-to-install.spec.tsx | 268 +++++ .../marketplace/__tests__/atoms.spec.tsx | 246 ++++ .../__tests__/hooks-integration.spec.tsx | 369 ++++++ .../marketplace/__tests__/hooks.spec.tsx | 371 +++--- .../__tests__/hydration-server.spec.tsx | 122 ++ .../marketplace/__tests__/index.spec.tsx | 102 +- .../__tests__/plugin-type-switch.spec.tsx | 124 ++ .../marketplace/__tests__/query.spec.tsx | 220 ++++ .../marketplace/__tests__/state.spec.tsx | 267 +++++ .../sticky-search-and-switch-wrapper.spec.tsx | 79 ++ .../marketplace/__tests__/utils.spec.ts | 162 +++ .../plugins/marketplace/hooks.spec.tsx | 597 ---------- .../plugin-auth/__tests__/index.spec.tsx | 240 +--- .../__tests__/plugin-auth.spec.tsx | 8 +- .../authorize/__tests__/index.spec.tsx | 64 +- .../authorized/__tests__/item.spec.tsx | 445 ++------ .../__tests__/endpoint-card.spec.tsx | 72 +- .../__tests__/endpoint-list.spec.tsx | 39 +- .../__tests__/endpoint-modal.spec.tsx | 160 +-- .../__tests__/index.spec.tsx | 25 +- .../__tests__/log-viewer.spec.tsx | 37 +- .../__tests__/selector-view.spec.tsx | 43 +- .../__tests__/subscription-card.spec.tsx | 18 +- .../components/__tests__/index.spec.tsx | 334 ++---- .../__tests__/version-mismatch-modal.spec.tsx | 1 + .../editor/form/__tests__/index.spec.tsx | 16 +- .../field-list/__tests__/index.spec.tsx | 554 ++++----- .../publisher/__tests__/index.spec.tsx | 110 +- .../__tests__/use-inspect-vars-crud.spec.ts | 99 ++ .../hooks/__tests__/use-pipeline-init.spec.ts | 1 + .../signin/{ => __tests__}/countdown.spec.tsx | 62 +- .../tools/__tests__/provider-list.spec.tsx | 325 +++++- .../mcp/__tests__/mcp-service-card.spec.tsx | 1011 ++++------------- web/app/components/tools/provider-list.tsx | 16 +- web/app/components/tools/utils/index.ts | 16 + .../chat-variable-trigger.spec.tsx | 4 +- .../{ => __tests__}/features-trigger.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 9 +- .../start-node-option.spec.tsx | 2 +- .../start-node-selection-panel.spec.tsx | 9 +- 75 files changed, 5652 insertions(+), 4081 deletions(-) rename web/app/components/custom/custom-page/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/custom/custom-web-app-brand/{ => __tests__}/index.spec.tsx (88%) create mode 100644 web/app/components/datasets/common/image-uploader/__tests__/constants.spec.ts create mode 100644 web/app/components/datasets/create/__tests__/icons.spec.ts create mode 100644 web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts create mode 100644 web/app/components/datasets/documents/components/__tests__/list.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/__tests__/metadata-field-list.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/hooks/__tests__/use-metadata-state.spec.ts create mode 100644 web/app/components/plugins/install-plugin/hooks/__tests__/use-fold-anim-into.spec.ts create mode 100644 web/app/components/plugins/install-plugin/install-bundle/__tests__/ready-to-install.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/hooks-integration.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/query.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/state.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/sticky-search-and-switch-wrapper.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/hooks.spec.tsx create mode 100644 web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts rename web/app/components/signin/{ => __tests__}/countdown.spec.tsx (81%) rename web/app/components/workflow-app/components/workflow-header/{ => __tests__}/chat-variable-trigger.spec.tsx (95%) rename web/app/components/workflow-app/components/workflow-header/{ => __tests__}/features-trigger.spec.tsx (99%) rename web/app/components/workflow-app/components/workflow-header/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/workflow-app/components/workflow-onboarding-modal/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/workflow-app/components/workflow-onboarding-modal/{ => __tests__}/start-node-option.spec.tsx (99%) rename web/app/components/workflow-app/components/workflow-onboarding-modal/{ => __tests__}/start-node-selection-panel.spec.tsx (98%) diff --git a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx index 9d435456b1..acee660f46 100644 --- a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx +++ b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx @@ -197,61 +197,30 @@ describe('AppsFull', () => { }) describe('Edge Cases', () => { - it('should use the success color when usage is below 50%', () => { - ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ - plan: { - ...baseProviderContextValue.plan, - type: Plan.sandbox, - usage: buildUsage({ buildApps: 2 }), - total: buildUsage({ buildApps: 5 }), - reset: { - apiRateLimit: null, - triggerEvents: null, + it('should apply distinct progress bar styling at different usage levels', () => { + const renderWithUsage = (used: number, total: number) => { + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + usage: buildUsage({ buildApps: used }), + total: buildUsage({ buildApps: total }), + reset: { apiRateLimit: null, triggerEvents: null }, }, - }, - })) + })) + const { unmount } = render(<AppsFull loc="billing_dialog" />) + const className = screen.getByTestId('billing-progress-bar').className + unmount() + return className + } - render(<AppsFull loc="billing_dialog" />) + const normalClass = renderWithUsage(2, 10) + const warningClass = renderWithUsage(6, 10) + const errorClass = renderWithUsage(8, 10) - expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid') - }) - - it('should use the warning color when usage is between 50% and 80%', () => { - ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ - plan: { - ...baseProviderContextValue.plan, - type: Plan.sandbox, - usage: buildUsage({ buildApps: 6 }), - total: buildUsage({ buildApps: 10 }), - reset: { - apiRateLimit: null, - triggerEvents: null, - }, - }, - })) - - render(<AppsFull loc="billing_dialog" />) - - expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress') - }) - - it('should use the error color when usage is 80% or higher', () => { - ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ - plan: { - ...baseProviderContextValue.plan, - type: Plan.sandbox, - usage: buildUsage({ buildApps: 8 }), - total: buildUsage({ buildApps: 10 }), - reset: { - apiRateLimit: null, - triggerEvents: null, - }, - }, - })) - - render(<AppsFull loc="billing_dialog" />) - - expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress') + expect(normalClass).not.toBe(warningClass) + expect(warningClass).not.toBe(errorClass) + expect(normalClass).not.toBe(errorClass) }) }) }) diff --git a/web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx b/web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx index fa4825b1f1..818e0e9b1b 100644 --- a/web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx +++ b/web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx @@ -70,7 +70,7 @@ describe('HeaderBillingBtn', () => { expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() }) - it('renders team badge for team plan with correct styling', () => { + it('renders team badge for team plan', () => { ensureProviderContextMock().mockReturnValueOnce({ plan: { type: Plan.team }, enableBilling: true, @@ -79,9 +79,7 @@ describe('HeaderBillingBtn', () => { render(<HeaderBillingBtn />) - const badge = screen.getByText('team').closest('div') - expect(badge).toBeInTheDocument() - expect(badge).toHaveClass('bg-[#E0EAFF]') + expect(screen.getByText('team')).toBeInTheDocument() }) it('renders nothing when plan is not fetched', () => { @@ -111,16 +109,11 @@ describe('HeaderBillingBtn', () => { const { rerender } = render(<HeaderBillingBtn onClick={onClick} />) - const badge = screen.getByText('pro').closest('div') - - expect(badge).toHaveClass('cursor-pointer') - - fireEvent.click(badge!) + const badge = screen.getByText('pro').closest('div')! + fireEvent.click(badge) expect(onClick).toHaveBeenCalledTimes(1) rerender(<HeaderBillingBtn onClick={onClick} isDisplayOnly />) - expect(screen.getByText('pro').closest('div')).toHaveClass('cursor-default') - fireEvent.click(screen.getByText('pro').closest('div')!) expect(onClick).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx b/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx index abb18b5126..ebe3ad43ef 100644 --- a/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx +++ b/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx @@ -47,8 +47,20 @@ describe('PlanSwitcherTab', () => { expect(handleClick).toHaveBeenCalledWith('self') }) - it('should apply active text class when isActive is true', () => { - render( + it('should apply distinct styling when isActive is true', () => { + const { rerender } = render( + <Tab + Icon={Icon} + value="cloud" + label="Cloud" + isActive={false} + onClick={vi.fn()} + />, + ) + + const inactiveClassName = screen.getByText('Cloud').className + + rerender( <Tab Icon={Icon} value="cloud" @@ -58,7 +70,8 @@ describe('PlanSwitcherTab', () => { />, ) - expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible') + const activeClassName = screen.getByText('Cloud').className + expect(activeClassName).not.toBe(inactiveClassName) expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true') }) }) diff --git a/web/app/components/billing/progress-bar/__tests__/index.spec.tsx b/web/app/components/billing/progress-bar/__tests__/index.spec.tsx index ffdbfb30e7..4310fab19d 100644 --- a/web/app/components/billing/progress-bar/__tests__/index.spec.tsx +++ b/web/app/components/billing/progress-bar/__tests__/index.spec.tsx @@ -7,7 +7,6 @@ describe('ProgressBar', () => { render(<ProgressBar percent={42} color="bg-test-color" />) const bar = screen.getByTestId('billing-progress-bar') - expect(bar).toHaveClass('bg-test-color') expect(bar.getAttribute('style')).toContain('width: 42%') }) @@ -18,11 +17,10 @@ describe('ProgressBar', () => { expect(bar.getAttribute('style')).toContain('width: 100%') }) - it('uses the default color when no color prop is provided', () => { + it('renders with default color when no color prop is provided', () => { render(<ProgressBar percent={20} color={undefined as unknown as string} />) const bar = screen.getByTestId('billing-progress-bar') - expect(bar).toHaveClass('bg-components-progress-bar-progress-solid') expect(bar.getAttribute('style')).toContain('width: 20%') }) }) @@ -31,9 +29,7 @@ describe('ProgressBar', () => { it('should render indeterminate progress bar when indeterminate is true', () => { render(<ProgressBar percent={0} color="bg-test-color" indeterminate />) - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toBeInTheDocument() - expect(bar).toHaveClass('bg-progress-bar-indeterminate-stripe') + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) it('should not render normal progress bar when indeterminate is true', () => { @@ -43,20 +39,20 @@ describe('ProgressBar', () => { expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) - it('should render with default width (w-[30px]) when indeterminateFull is false', () => { - render(<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />) + it('should render with different width based on indeterminateFull prop', () => { + const { rerender } = render( + <ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />, + ) const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-[30px]') - expect(bar).not.toHaveClass('w-full') - }) + const partialClassName = bar.className - it('should render with full width (w-full) when indeterminateFull is true', () => { - render(<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />) + rerender( + <ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />, + ) - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-full') - expect(bar).not.toHaveClass('w-[30px]') + const fullClassName = screen.getByTestId('billing-progress-bar-indeterminate').className + expect(partialClassName).not.toBe(fullClassName) }) }) }) diff --git a/web/app/components/billing/usage-info/__tests__/index.spec.tsx b/web/app/components/billing/usage-info/__tests__/index.spec.tsx index b781ef7746..3cbab5c662 100644 --- a/web/app/components/billing/usage-info/__tests__/index.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/index.spec.tsx @@ -71,8 +71,19 @@ describe('UsageInfo', () => { expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument() }) - it('applies warning color when usage is close to the limit', () => { - render( + it('applies distinct styling when usage is close to or exceeds the limit', () => { + const { rerender } = render( + <UsageInfo + Icon={TestIcon} + name="Storage" + usage={30} + total={100} + />, + ) + + const normalBarClass = screen.getByTestId('billing-progress-bar').className + + rerender( <UsageInfo Icon={TestIcon} name="Storage" @@ -81,12 +92,10 @@ describe('UsageInfo', () => { />, ) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-warning-progress') - }) + const warningBarClass = screen.getByTestId('billing-progress-bar').className + expect(warningBarClass).not.toBe(normalBarClass) - it('applies error color when usage exceeds the limit', () => { - render( + rerender( <UsageInfo Icon={TestIcon} name="Storage" @@ -95,8 +104,9 @@ describe('UsageInfo', () => { />, ) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') + const errorBarClass = screen.getByTestId('billing-progress-bar').className + expect(errorBarClass).not.toBe(normalBarClass) + expect(errorBarClass).not.toBe(warningBarClass) }) it('does not render the icon when hideIcon is true', () => { @@ -173,8 +183,8 @@ describe('UsageInfo', () => { expect(screen.getAllByText('MB').length).toBeGreaterThanOrEqual(1) }) - it('should render full-width indeterminate bar for sandbox users below threshold', () => { - render( + it('should render different indeterminate bar widths for sandbox vs non-sandbox', () => { + const { rerender } = render( <UsageInfo Icon={TestIcon} name="Storage" @@ -187,12 +197,9 @@ describe('UsageInfo', () => { />, ) - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-full') - }) + const sandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className - it('should render narrow indeterminate bar for non-sandbox users below threshold', () => { - render( + rerender( <UsageInfo Icon={TestIcon} name="Storage" @@ -205,13 +212,13 @@ describe('UsageInfo', () => { />, ) - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-[30px]') + const nonSandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className + expect(sandboxBarClass).not.toBe(nonSandboxBarClass) }) }) describe('Sandbox Full Capacity', () => { - it('should render error color progress bar when sandbox usage >= threshold', () => { + it('should render determinate progress bar when sandbox usage >= threshold', () => { render( <UsageInfo Icon={TestIcon} @@ -225,8 +232,8 @@ describe('UsageInfo', () => { />, ) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') + expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() + expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() }) it('should display "threshold / threshold unit" format when sandbox is at full capacity', () => { @@ -305,9 +312,7 @@ describe('UsageInfo', () => { />, ) - // Tooltip wrapper should contain cursor-default class - const tooltipWrapper = container.querySelector('.cursor-default') - expect(tooltipWrapper).toBeInTheDocument() + expect(container.querySelector('[data-state]')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx index 3da67f02af..041845ab3b 100644 --- a/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx @@ -61,11 +61,10 @@ describe('VectorSpaceInfo', () => { expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) - it('should render full-width indeterminate bar for sandbox users', () => { + it('should render indeterminate bar for sandbox users', () => { render(<VectorSpaceInfo />) - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-full') + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) it('should display "< 50" format for sandbox below threshold', () => { @@ -81,11 +80,11 @@ describe('VectorSpaceInfo', () => { mockVectorSpaceUsage = 50 }) - it('should render error color progress bar when at full capacity', () => { + it('should render determinate progress bar when at full capacity', () => { render(<VectorSpaceInfo />) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') + expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() + expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() }) it('should display "50 / 50 MB" format when at full capacity', () => { @@ -108,19 +107,10 @@ describe('VectorSpaceInfo', () => { expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) - it('should render narrow indeterminate bar (not full width)', () => { - render(<VectorSpaceInfo />) - - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-[30px]') - expect(bar).not.toHaveClass('w-full') - }) - it('should display "< 50 / total" format when below threshold', () => { render(<VectorSpaceInfo />) expect(screen.getByText(/< 50/)).toBeInTheDocument() - // 5 GB = 5120 MB expect(screen.getByText('5120MB')).toBeInTheDocument() }) }) @@ -158,14 +148,6 @@ describe('VectorSpaceInfo', () => { expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) - it('should render narrow indeterminate bar (not full width)', () => { - render(<VectorSpaceInfo />) - - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-[30px]') - expect(bar).not.toHaveClass('w-full') - }) - it('should display "< 50 / total" format when below threshold', () => { render(<VectorSpaceInfo />) @@ -196,51 +178,24 @@ describe('VectorSpaceInfo', () => { }) }) - describe('Pro/Team Plan Warning State', () => { - it('should show warning color when Professional plan usage approaches limit (80%+)', () => { + describe('Pro/Team Plan Usage States', () => { + const renderAndGetBarClass = (usage: number) => { mockPlanType = Plan.professional - // 5120 MB * 80% = 4096 MB - mockVectorSpaceUsage = 4100 + mockVectorSpaceUsage = usage + const { unmount } = render(<VectorSpaceInfo />) + const className = screen.getByTestId('billing-progress-bar').className + unmount() + return className + } - render(<VectorSpaceInfo />) + it('should show distinct progress bar styling at different usage levels', () => { + const normalClass = renderAndGetBarClass(100) + const warningClass = renderAndGetBarClass(4100) + const errorClass = renderAndGetBarClass(5200) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-warning-progress') - }) - - it('should show warning color when Team plan usage approaches limit (80%+)', () => { - mockPlanType = Plan.team - // 20480 MB * 80% = 16384 MB - mockVectorSpaceUsage = 16500 - - render(<VectorSpaceInfo />) - - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-warning-progress') - }) - }) - - describe('Pro/Team Plan Error State', () => { - it('should show error color when Professional plan usage exceeds limit', () => { - mockPlanType = Plan.professional - // Exceeds 5120 MB - mockVectorSpaceUsage = 5200 - - render(<VectorSpaceInfo />) - - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') - }) - - it('should show error color when Team plan usage exceeds limit', () => { - mockPlanType = Plan.team - // Exceeds 20480 MB - mockVectorSpaceUsage = 21000 - - render(<VectorSpaceInfo />) - - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') + expect(normalClass).not.toBe(warningClass) + expect(warningClass).not.toBe(errorClass) + expect(normalClass).not.toBe(errorClass) }) }) @@ -265,12 +220,10 @@ describe('VectorSpaceInfo', () => { expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) - it('should render narrow indeterminate bar (not full width) for enterprise', () => { + it('should render indeterminate bar for enterprise below threshold', () => { render(<VectorSpaceInfo />) - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-[30px]') - expect(bar).not.toHaveClass('w-full') + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) it('should display "< 50 / total" format when below threshold', () => { diff --git a/web/app/components/custom/custom-page/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/custom/custom-page/index.spec.tsx rename to web/app/components/custom/custom-page/__tests__/index.spec.tsx index e30fe67ea7..0da27e06a6 100644 --- a/web/app/components/custom/custom-page/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -6,11 +6,8 @@ import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { contactSalesUrl } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' import { useModalContext } from '@/context/modal-context' -// Get the mocked functions -// const { useProviderContext } = vi.requireMock('@/context/provider-context') -// const { useModalContext } = vi.requireMock('@/context/modal-context') import { useProviderContext } from '@/context/provider-context' -import CustomPage from './index' +import CustomPage from '../index' // Mock external dependencies only vi.mock('@/context/provider-context', () => ({ @@ -23,7 +20,7 @@ vi.mock('@/context/modal-context', () => ({ // Mock the complex CustomWebAppBrand component to avoid dependency issues // This is acceptable because it has complex dependencies (fetch, APIs) -vi.mock('../custom-web-app-brand', () => ({ +vi.mock('@/app/components/custom/custom-web-app-brand', () => ({ default: () => <div data-testid="custom-web-app-brand">CustomWebAppBrand</div>, })) diff --git a/web/app/components/custom/custom-web-app-brand/index.spec.tsx b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/custom/custom-web-app-brand/index.spec.tsx rename to web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx index e50ca4e9b2..2ceb45235c 100644 --- a/web/app/components/custom/custom-web-app-brand/index.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { updateCurrentWorkspace } from '@/service/common' -import CustomWebAppBrand from './index' +import CustomWebAppBrand from '../index' vi.mock('@/app/components/base/toast', () => ({ useToastContext: vi.fn(), @@ -53,8 +53,8 @@ const renderComponent = () => render(<CustomWebAppBrand />) describe('CustomWebAppBrand', () => { beforeEach(() => { vi.clearAllMocks() - mockUseToastContext.mockReturnValue({ notify: mockNotify } as any) - mockUpdateCurrentWorkspace.mockResolvedValue({} as any) + mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType<typeof useToastContext>) + mockUpdateCurrentWorkspace.mockResolvedValue({} as unknown as Awaited<ReturnType<typeof updateCurrentWorkspace>>) mockUseAppContext.mockReturnValue({ currentWorkspace: { custom_config: { @@ -64,7 +64,7 @@ describe('CustomWebAppBrand', () => { }, mutateCurrentWorkspace: vi.fn(), isCurrentWorkspaceManager: true, - } as any) + } as unknown as ReturnType<typeof useAppContext>) mockUseProviderContext.mockReturnValue({ plan: { type: Plan.professional, @@ -73,14 +73,14 @@ describe('CustomWebAppBrand', () => { reset: {}, }, enableBilling: false, - } as any) + } as unknown as ReturnType<typeof useProviderContext>) const systemFeaturesState = { branding: { enabled: true, workspace_logo: 'https://example.com/workspace-logo.png', }, } - mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState } as any) : { systemFeatures: systemFeaturesState }) + mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState, setSystemFeatures: vi.fn() } as unknown as ReturnType<typeof useGlobalPublicStore.getState>) : { systemFeatures: systemFeaturesState }) mockGetImageUploadErrorMessage.mockReturnValue('upload error') }) @@ -94,7 +94,7 @@ describe('CustomWebAppBrand', () => { }, mutateCurrentWorkspace: vi.fn(), isCurrentWorkspaceManager: false, - } as any) + } as unknown as ReturnType<typeof useAppContext>) const { container } = renderComponent() const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement @@ -112,7 +112,7 @@ describe('CustomWebAppBrand', () => { }, mutateCurrentWorkspace: mutateMock, isCurrentWorkspaceManager: true, - } as any) + } as unknown as ReturnType<typeof useAppContext>) renderComponent() const switchInput = screen.getByRole('switch') diff --git a/web/app/components/datasets/common/image-uploader/__tests__/constants.spec.ts b/web/app/components/datasets/common/image-uploader/__tests__/constants.spec.ts new file mode 100644 index 0000000000..925fa3af23 --- /dev/null +++ b/web/app/components/datasets/common/image-uploader/__tests__/constants.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + ACCEPT_TYPES, + DEFAULT_IMAGE_FILE_BATCH_LIMIT, + DEFAULT_IMAGE_FILE_SIZE_LIMIT, + DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT, +} from '../constants' + +describe('image-uploader constants', () => { + // Verify accepted image types + describe('ACCEPT_TYPES', () => { + it('should include standard image formats', () => { + expect(ACCEPT_TYPES).toContain('jpg') + expect(ACCEPT_TYPES).toContain('jpeg') + expect(ACCEPT_TYPES).toContain('png') + expect(ACCEPT_TYPES).toContain('gif') + }) + + it('should have exactly 4 types', () => { + expect(ACCEPT_TYPES).toHaveLength(4) + }) + }) + + // Verify numeric limits are positive + describe('Limits', () => { + it('should have a positive file size limit', () => { + expect(DEFAULT_IMAGE_FILE_SIZE_LIMIT).toBeGreaterThan(0) + expect(DEFAULT_IMAGE_FILE_SIZE_LIMIT).toBe(2) + }) + + it('should have a positive batch limit', () => { + expect(DEFAULT_IMAGE_FILE_BATCH_LIMIT).toBeGreaterThan(0) + expect(DEFAULT_IMAGE_FILE_BATCH_LIMIT).toBe(5) + }) + + it('should have a positive single chunk attachment limit', () => { + expect(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT).toBeGreaterThan(0) + expect(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT).toBe(10) + }) + }) +}) diff --git a/web/app/components/datasets/create/__tests__/icons.spec.ts b/web/app/components/datasets/create/__tests__/icons.spec.ts new file mode 100644 index 0000000000..780c0bf4c0 --- /dev/null +++ b/web/app/components/datasets/create/__tests__/icons.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { indexMethodIcon, retrievalIcon } from '../icons' + +describe('create/icons', () => { + // Verify icon map exports have expected keys + describe('indexMethodIcon', () => { + it('should have high_quality and economical keys', () => { + expect(indexMethodIcon).toHaveProperty('high_quality') + expect(indexMethodIcon).toHaveProperty('economical') + }) + + it('should have truthy values for each key', () => { + expect(indexMethodIcon.high_quality).toBeTruthy() + expect(indexMethodIcon.economical).toBeTruthy() + }) + }) + + describe('retrievalIcon', () => { + it('should have vector, fullText, and hybrid keys', () => { + expect(retrievalIcon).toHaveProperty('vector') + expect(retrievalIcon).toHaveProperty('fullText') + expect(retrievalIcon).toHaveProperty('hybrid') + }) + + it('should have truthy values for each key', () => { + expect(retrievalIcon.vector).toBeTruthy() + expect(retrievalIcon.fullText).toBeTruthy() + expect(retrievalIcon.hybrid).toBeTruthy() + }) + }) +}) diff --git a/web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts b/web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts new file mode 100644 index 0000000000..3659ecce79 --- /dev/null +++ b/web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + PROGRESS_COMPLETE, + PROGRESS_ERROR, + PROGRESS_NOT_STARTED, +} from '../constants' + +describe('file-uploader constants', () => { + // Verify progress sentinel values + describe('Progress Sentinels', () => { + it('should define PROGRESS_NOT_STARTED as -1', () => { + expect(PROGRESS_NOT_STARTED).toBe(-1) + }) + + it('should define PROGRESS_ERROR as -2', () => { + expect(PROGRESS_ERROR).toBe(-2) + }) + + it('should define PROGRESS_COMPLETE as 100', () => { + expect(PROGRESS_COMPLETE).toBe(100) + }) + + it('should have distinct values for all sentinels', () => { + const values = [PROGRESS_NOT_STARTED, PROGRESS_ERROR, PROGRESS_COMPLETE] + expect(new Set(values).size).toBe(values.length) + }) + + it('should have negative values for non-progress states', () => { + expect(PROGRESS_NOT_STARTED).toBeLessThan(0) + expect(PROGRESS_ERROR).toBeLessThan(0) + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/__tests__/list.spec.tsx b/web/app/components/datasets/documents/components/__tests__/list.spec.tsx new file mode 100644 index 0000000000..a96afe3cb4 --- /dev/null +++ b/web/app/components/datasets/documents/components/__tests__/list.spec.tsx @@ -0,0 +1,240 @@ +import type { SimpleDocumentDetail } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useDocumentSort } from '../document-list/hooks' +import DocumentList from '../list' + +// Mock hooks used by DocumentList +const mockHandleSort = vi.fn() +const mockOnSelectAll = vi.fn() +const mockOnSelectOne = vi.fn() +const mockClearSelection = vi.fn() +const mockHandleAction = vi.fn(() => vi.fn()) +const mockHandleBatchReIndex = vi.fn() +const mockHandleBatchDownload = vi.fn() +const mockShowEditModal = vi.fn() +const mockHideEditModal = vi.fn() +const mockHandleSave = vi.fn() + +vi.mock('../document-list/hooks', () => ({ + useDocumentSort: vi.fn(() => ({ + sortField: null, + sortOrder: null, + handleSort: mockHandleSort, + sortedDocuments: [], + })), + useDocumentSelection: vi.fn(() => ({ + isAllSelected: false, + isSomeSelected: false, + onSelectAll: mockOnSelectAll, + onSelectOne: mockOnSelectOne, + hasErrorDocumentsSelected: false, + downloadableSelectedIds: [], + clearSelection: mockClearSelection, + })), + useDocumentActions: vi.fn(() => ({ + handleAction: mockHandleAction, + handleBatchReIndex: mockHandleBatchReIndex, + handleBatchDownload: mockHandleBatchDownload, + })), +})) + +vi.mock('@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata', () => ({ + default: vi.fn(() => ({ + isShowEditModal: false, + showEditModal: mockShowEditModal, + hideEditModal: mockHideEditModal, + originalList: [], + handleSave: mockHandleSave, + })), +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: () => ({ + doc_form: 'text_model', + }), +})) + +// Mock child components that are complex +vi.mock('../document-list/components', () => ({ + DocumentTableRow: ({ doc, index }: { doc: SimpleDocumentDetail, index: number }) => ( + <tr data-testid={`doc-row-${doc.id}`}> + <td>{index + 1}</td> + <td>{doc.name}</td> + </tr> + ), + renderTdValue: (val: string) => val || '-', + SortHeader: ({ field, label, onSort }: { field: string, label: string, onSort: (f: string) => void }) => ( + <button data-testid={`sort-${field}`} onClick={() => onSort(field)}>{label}</button> + ), +})) + +vi.mock('../../detail/completed/common/batch-action', () => ({ + default: ({ selectedIds, onCancel }: { selectedIds: string[], onCancel: () => void }) => ( + <div data-testid="batch-action"> + <span data-testid="selected-count">{selectedIds.length}</span> + <button data-testid="cancel-selection" onClick={onCancel}>Cancel</button> + </div> + ), +})) + +vi.mock('../../rename-modal', () => ({ + default: ({ name, onClose }: { name: string, onClose: () => void }) => ( + <div data-testid="rename-modal"> + <span>{name}</span> + <button onClick={onClose}>Close</button> + </div> + ), +})) + +vi.mock('@/app/components/datasets/metadata/edit-metadata-batch/modal', () => ({ + default: ({ onHide }: { onHide: () => void }) => ( + <div data-testid="edit-metadata-modal"> + <button onClick={onHide}>Hide</button> + </div> + ), +})) + +function createDoc(overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail { + return { + id: `doc-${Math.random().toString(36).slice(2, 8)}`, + name: 'Test Doc', + position: 1, + data_source_type: 'upload_file', + word_count: 100, + hit_count: 5, + indexing_status: 'completed', + enabled: true, + disabled_at: null, + disabled_by: null, + archived: false, + display_status: 'available', + created_from: 'web', + created_at: 1234567890, + ...overrides, + } as SimpleDocumentDetail +} + +const defaultProps = { + embeddingAvailable: true, + documents: [] as SimpleDocumentDetail[], + selectedIds: [] as string[], + onSelectedIdChange: vi.fn(), + datasetId: 'ds-1', + pagination: { total: 0, current: 1, limit: 10, onChange: vi.fn() }, + onUpdate: vi.fn(), + onManageMetadata: vi.fn(), + statusFilterValue: 'all', + remoteSortValue: '', +} + +describe('DocumentList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify the table renders with column headers + describe('Rendering', () => { + it('should render the document table with headers', () => { + render(<DocumentList {...defaultProps} />) + + expect(screen.getByText('#')).toBeInTheDocument() + expect(screen.getByTestId('sort-name')).toBeInTheDocument() + expect(screen.getByTestId('sort-word_count')).toBeInTheDocument() + expect(screen.getByTestId('sort-hit_count')).toBeInTheDocument() + expect(screen.getByTestId('sort-created_at')).toBeInTheDocument() + }) + + it('should render select-all area when embeddingAvailable is true', () => { + const { container } = render(<DocumentList {...defaultProps} embeddingAvailable={true} />) + + // Checkbox component renders inside the first td + const firstTd = container.querySelector('thead td') + expect(firstTd?.textContent).toContain('#') + }) + + it('should still render # column when embeddingAvailable is false', () => { + const { container } = render(<DocumentList {...defaultProps} embeddingAvailable={false} />) + + const firstTd = container.querySelector('thead td') + expect(firstTd?.textContent).toContain('#') + }) + + it('should render document rows from sortedDocuments', () => { + const docs = [createDoc({ id: 'a', name: 'Doc A' }), createDoc({ id: 'b', name: 'Doc B' })] + vi.mocked(useDocumentSort).mockReturnValue({ + sortField: null, + sortOrder: 'desc', + handleSort: mockHandleSort, + sortedDocuments: docs, + } as unknown as ReturnType<typeof useDocumentSort>) + + render(<DocumentList {...defaultProps} documents={docs} />) + + expect(screen.getByTestId('doc-row-a')).toBeInTheDocument() + expect(screen.getByTestId('doc-row-b')).toBeInTheDocument() + }) + }) + + // Verify sort headers trigger sort handler + describe('Sorting', () => { + it('should call handleSort when sort header is clicked', () => { + render(<DocumentList {...defaultProps} />) + + fireEvent.click(screen.getByTestId('sort-name')) + + expect(mockHandleSort).toHaveBeenCalledWith('name') + }) + }) + + // Verify batch action bar appears when items selected + describe('Batch Actions', () => { + it('should show batch action bar when selectedIds is non-empty', () => { + render(<DocumentList {...defaultProps} selectedIds={['doc-1']} />) + + expect(screen.getByTestId('batch-action')).toBeInTheDocument() + expect(screen.getByTestId('selected-count')).toHaveTextContent('1') + }) + + it('should not show batch action bar when no items selected', () => { + render(<DocumentList {...defaultProps} selectedIds={[]} />) + + expect(screen.queryByTestId('batch-action')).not.toBeInTheDocument() + }) + + it('should call clearSelection when cancel is clicked in batch bar', () => { + render(<DocumentList {...defaultProps} selectedIds={['doc-1']} />) + + fireEvent.click(screen.getByTestId('cancel-selection')) + + expect(mockClearSelection).toHaveBeenCalled() + }) + }) + + // Verify pagination renders when total > 0 + describe('Pagination', () => { + it('should not render pagination when total is 0', () => { + const { container } = render(<DocumentList {...defaultProps} />) + + expect(container.querySelector('[class*="pagination"]')).not.toBeInTheDocument() + }) + }) + + // Verify empty state + describe('Edge Cases', () => { + it('should render table with no document rows when sortedDocuments is empty', () => { + // Reset sort mock to return empty sorted list + vi.mocked(useDocumentSort).mockReturnValue({ + sortField: null, + sortOrder: 'desc', + handleSort: mockHandleSort, + sortedDocuments: [], + } as unknown as ReturnType<typeof useDocumentSort>) + + render(<DocumentList {...defaultProps} documents={[]} />) + + expect(screen.queryByTestId(/^doc-row-/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx new file mode 100644 index 0000000000..25ac817284 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx @@ -0,0 +1,167 @@ +import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import Toast from '@/app/components/base/toast' + +import Form from '../form' + +// Mock the Header component (sibling component, not a base component) +vi.mock('../header', () => ({ + default: ({ onReset, resetDisabled, onPreview, previewDisabled }: { + onReset: () => void + resetDisabled: boolean + onPreview: () => void + previewDisabled: boolean + }) => ( + <div data-testid="form-header"> + <button data-testid="reset-btn" onClick={onReset} disabled={resetDisabled}>Reset</button> + <button data-testid="preview-btn" onClick={onPreview} disabled={previewDisabled}>Preview</button> + </div> + ), +})) + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), + value: z.string().optional(), +}) + +const defaultConfigs: BaseConfiguration[] = [ + { variable: 'name', type: 'text-input', label: 'Name', required: true, showConditions: [] } as BaseConfiguration, + { variable: 'value', type: 'text-input', label: 'Value', required: false, showConditions: [] } as BaseConfiguration, +] + +const defaultProps = { + initialData: { name: 'test', value: '' }, + configurations: defaultConfigs, + schema, + onSubmit: vi.fn(), + onPreview: vi.fn(), + ref: { current: null }, + isRunning: false, +} + +describe('Form (process-documents)', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + }) + + // Verify basic rendering of form structure + describe('Rendering', () => { + it('should render form with header and fields', () => { + render(<Form {...defaultProps} />) + + expect(screen.getByTestId('form-header')).toBeInTheDocument() + expect(screen.getByText('Name')).toBeInTheDocument() + expect(screen.getByText('Value')).toBeInTheDocument() + }) + + it('should render all configuration fields', () => { + const configs: BaseConfiguration[] = [ + { variable: 'a', type: 'text-input', label: 'A', required: false, showConditions: [] } as BaseConfiguration, + { variable: 'b', type: 'text-input', label: 'B', required: false, showConditions: [] } as BaseConfiguration, + { variable: 'c', type: 'text-input', label: 'C', required: false, showConditions: [] } as BaseConfiguration, + ] + + render(<Form {...defaultProps} configurations={configs} initialData={{ a: '', b: '', c: '' }} />) + + expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.getByText('B')).toBeInTheDocument() + expect(screen.getByText('C')).toBeInTheDocument() + }) + }) + + // Verify form submission behavior + describe('Form Submission', () => { + it('should call onSubmit with valid data on form submit', async () => { + render(<Form {...defaultProps} />) + const form = screen.getByTestId('form-header').closest('form')! + + fireEvent.submit(form) + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalled() + }) + }) + + it('should call onSubmit with valid data via imperative handle', async () => { + const ref = { current: null as { submit: () => void } | null } + render(<Form {...defaultProps} ref={ref} />) + + ref.current?.submit() + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalled() + }) + }) + }) + + // Verify validation shows Toast on error + describe('Validation', () => { + it('should show toast error when validation fails', async () => { + render(<Form {...defaultProps} initialData={{ name: '', value: '' }} />) + const form = screen.getByTestId('form-header').closest('form')! + + fireEvent.submit(form) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) + + it('should not show toast error when validation passes', async () => { + render(<Form {...defaultProps} />) + const form = screen.getByTestId('form-header').closest('form')! + + fireEvent.submit(form) + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalled() + }) + expect(Toast.notify).not.toHaveBeenCalled() + }) + }) + + // Verify header button states + describe('Header Controls', () => { + it('should pass isRunning to previewDisabled', () => { + render(<Form {...defaultProps} isRunning={true} />) + + expect(screen.getByTestId('preview-btn')).toBeDisabled() + }) + + it('should call onPreview when preview button is clicked', () => { + render(<Form {...defaultProps} />) + + fireEvent.click(screen.getByTestId('preview-btn')) + + expect(defaultProps.onPreview).toHaveBeenCalled() + }) + + it('should render reset button (disabled when form is not dirty)', () => { + render(<Form {...defaultProps} />) + + // Reset button is rendered but disabled since form is not dirty initially + expect(screen.getByTestId('reset-btn')).toBeInTheDocument() + expect(screen.getByTestId('reset-btn')).toBeDisabled() + }) + }) + + // Verify edge cases + describe('Edge Cases', () => { + it('should render with empty configurations array', () => { + render(<Form {...defaultProps} configurations={[]} />) + + expect(screen.getByTestId('form-header')).toBeInTheDocument() + }) + + it('should render with empty initialData', () => { + render(<Form {...defaultProps} initialData={{}} configurations={[]} />) + + expect(screen.getByTestId('form-header')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx new file mode 100644 index 0000000000..55295579f0 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx @@ -0,0 +1,147 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import DocTypeSelector, { DocumentTypeDisplay } from '../doc-type-selector' + +vi.mock('@/hooks/use-metadata', () => ({ + useMetadataMap: () => ({ + book: { text: 'Book', iconName: 'book' }, + web_page: { text: 'Web Page', iconName: 'web' }, + paper: { text: 'Paper', iconName: 'paper' }, + social_media_post: { text: 'Social Media Post', iconName: 'social' }, + personal_document: { text: 'Personal Document', iconName: 'personal' }, + business_document: { text: 'Business Document', iconName: 'business' }, + wikipedia_entry: { text: 'Wikipedia', iconName: 'wiki' }, + }), +})) + +vi.mock('@/models/datasets', async (importOriginal) => { + const actual = await importOriginal() as Record<string, unknown> + return { + ...actual, + CUSTOMIZABLE_DOC_TYPES: ['book', 'web_page', 'paper'], + } +}) + +describe('DocTypeSelector', () => { + const defaultProps = { + docType: '' as '' | 'book', + documentType: undefined as '' | 'book' | undefined, + tempDocType: '' as '' | 'book' | 'web_page', + onTempDocTypeChange: vi.fn(), + onConfirm: vi.fn(), + onCancel: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify first-time setup UI (no existing doc type) + describe('First Time Selection', () => { + it('should render description and selection title when no doc type exists', () => { + render(<DocTypeSelector {...defaultProps} docType="" documentType={undefined} />) + + expect(screen.getByText(/metadata\.desc/)).toBeInTheDocument() + expect(screen.getByText(/metadata\.docTypeSelectTitle/)).toBeInTheDocument() + }) + + it('should render icon buttons for each doc type', () => { + const { container } = render(<DocTypeSelector {...defaultProps} />) + + // Each doc type renders an IconButton wrapped in Radio + const iconButtons = container.querySelectorAll('button[type="button"]') + // 3 doc types + 1 confirm button = 4 buttons + expect(iconButtons.length).toBeGreaterThanOrEqual(3) + }) + + it('should render confirm button disabled when tempDocType is empty', () => { + render(<DocTypeSelector {...defaultProps} tempDocType="" />) + + const confirmBtn = screen.getByText(/metadata\.firstMetaAction/) + expect(confirmBtn.closest('button')).toBeDisabled() + }) + + it('should render confirm button enabled when tempDocType is set', () => { + render(<DocTypeSelector {...defaultProps} tempDocType="book" />) + + const confirmBtn = screen.getByText(/metadata\.firstMetaAction/) + expect(confirmBtn.closest('button')).not.toBeDisabled() + }) + + it('should call onConfirm when confirm button is clicked', () => { + render(<DocTypeSelector {...defaultProps} tempDocType="book" />) + + fireEvent.click(screen.getByText(/metadata\.firstMetaAction/)) + + expect(defaultProps.onConfirm).toHaveBeenCalled() + }) + }) + + // Verify change-type UI (has existing doc type) + describe('Change Doc Type', () => { + it('should render change title and warning when documentType exists', () => { + render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />) + + expect(screen.getByText(/metadata\.docTypeChangeTitle/)).toBeInTheDocument() + expect(screen.getByText(/metadata\.docTypeSelectWarning/)).toBeInTheDocument() + }) + + it('should render save and cancel buttons when documentType exists', () => { + render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />) + + expect(screen.getByText(/operation\.save/)).toBeInTheDocument() + expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument() + }) + + it('should call onCancel when cancel button is clicked', () => { + render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />) + + fireEvent.click(screen.getByText(/operation\.cancel/)) + + expect(defaultProps.onCancel).toHaveBeenCalled() + }) + }) +}) + +describe('DocumentTypeDisplay', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify read-only display of current doc type + describe('Rendering', () => { + it('should render the doc type text', () => { + render(<DocumentTypeDisplay displayType="book" />) + + expect(screen.getByText('Book')).toBeInTheDocument() + }) + + it('should show change link when showChangeLink is true', () => { + render(<DocumentTypeDisplay displayType="book" showChangeLink={true} />) + + expect(screen.getByText(/operation\.change/)).toBeInTheDocument() + }) + + it('should not show change link when showChangeLink is false', () => { + render(<DocumentTypeDisplay displayType="book" showChangeLink={false} />) + + expect(screen.queryByText(/operation\.change/)).not.toBeInTheDocument() + }) + + it('should call onChangeClick when change link is clicked', () => { + const onClick = vi.fn() + render(<DocumentTypeDisplay displayType="book" showChangeLink={true} onChangeClick={onClick} />) + + fireEvent.click(screen.getByText(/operation\.change/)) + + expect(onClick).toHaveBeenCalled() + }) + + it('should fallback to "book" display when displayType is empty and no change link', () => { + render(<DocumentTypeDisplay displayType="" showChangeLink={false} />) + + expect(screen.getByText('Book')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx new file mode 100644 index 0000000000..8a826ada39 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import FieldInfo from '../field-info' + +vi.mock('@/utils', () => ({ + getTextWidthWithCanvas: (text: string) => text.length * 8, +})) + +describe('FieldInfo', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify read-only rendering + describe('Read-Only Mode', () => { + it('should render label and displayed value', () => { + render(<FieldInfo label="Title" displayedValue="My Document" />) + + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('My Document')).toBeInTheDocument() + }) + + it('should render value icon when provided', () => { + render( + <FieldInfo + label="Status" + displayedValue="Active" + valueIcon={<span data-testid="icon">*</span>} + />, + ) + + expect(screen.getByTestId('icon')).toBeInTheDocument() + }) + + it('should render displayedValue as plain text when not editing', () => { + render(<FieldInfo label="Author" displayedValue="John" showEdit={false} />) + + expect(screen.getByText('John')).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + }) + + // Verify edit mode rendering for each inputType + describe('Edit Mode', () => { + it('should render input field by default in edit mode', () => { + render(<FieldInfo label="Title" value="Test" showEdit={true} inputType="input" />) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveValue('Test') + }) + + it('should render textarea when inputType is textarea', () => { + render(<FieldInfo label="Desc" value="Long text" showEdit={true} inputType="textarea" />) + + const textarea = screen.getByRole('textbox') + expect(textarea).toBeInTheDocument() + expect(textarea).toHaveValue('Long text') + }) + + it('should render select when inputType is select', () => { + const options = [ + { value: 'en', name: 'English' }, + { value: 'zh', name: 'Chinese' }, + ] + render( + <FieldInfo + label="Language" + value="en" + showEdit={true} + inputType="select" + selectOptions={options} + />, + ) + + // SimpleSelect renders a button-like trigger + expect(screen.getByText('English')).toBeInTheDocument() + }) + + it('should call onUpdate when input value changes', () => { + const onUpdate = vi.fn() + render(<FieldInfo label="Title" value="" showEdit={true} inputType="input" onUpdate={onUpdate} />) + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } }) + + expect(onUpdate).toHaveBeenCalledWith('New') + }) + + it('should call onUpdate when textarea value changes', () => { + const onUpdate = vi.fn() + render(<FieldInfo label="Desc" value="" showEdit={true} inputType="textarea" onUpdate={onUpdate} />) + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } }) + + expect(onUpdate).toHaveBeenCalledWith('Updated') + }) + }) + + // Verify edge cases + describe('Edge Cases', () => { + it('should render with empty value and label', () => { + render(<FieldInfo label="" value="" displayedValue="" />) + + // Should not crash + const container = document.querySelector('.flex.min-h-5') + expect(container).toBeInTheDocument() + }) + + it('should render with default value prop', () => { + render(<FieldInfo label="Field" showEdit={true} inputType="input" defaultValue="default" />) + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/__tests__/metadata-field-list.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/__tests__/metadata-field-list.spec.tsx new file mode 100644 index 0000000000..cc5b16fc3e --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/__tests__/metadata-field-list.spec.tsx @@ -0,0 +1,149 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import MetadataFieldList from '../metadata-field-list' + +vi.mock('@/hooks/use-metadata', () => ({ + useMetadataMap: () => ({ + book: { + text: 'Book', + subFieldsMap: { + title: { label: 'Title', inputType: 'input' }, + language: { label: 'Language', inputType: 'select' }, + author: { label: 'Author', inputType: 'input' }, + }, + }, + originInfo: { + text: 'Origin Info', + subFieldsMap: { + source: { label: 'Source', inputType: 'input' }, + hit_count: { label: 'Hit Count', inputType: 'input', render: (val: number, segCount?: number) => `${val} / ${segCount}` }, + }, + }, + }), + useLanguages: () => ({ en: 'English', zh: 'Chinese' }), + useBookCategories: () => ({ fiction: 'Fiction', nonfiction: 'Non-fiction' }), + usePersonalDocCategories: () => ({}), + useBusinessDocCategories: () => ({}), +})) + +describe('MetadataFieldList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify rendering of metadata fields based on mainField + describe('Rendering', () => { + it('should render all fields for the given mainField', () => { + render( + <MetadataFieldList + mainField="book" + metadata={{ title: 'Test Book', language: 'en', author: 'John' }} + />, + ) + + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Language')).toBeInTheDocument() + expect(screen.getByText('Author')).toBeInTheDocument() + }) + + it('should return null when mainField is empty', () => { + const { container } = render( + <MetadataFieldList mainField="" metadata={{}} />, + ) + + expect(container.firstChild).toBeNull() + }) + + it('should display "-" for missing field values', () => { + render( + <MetadataFieldList + mainField="book" + metadata={{}} + />, + ) + + // All three fields should show "-" + const dashes = screen.getAllByText('-') + expect(dashes.length).toBeGreaterThanOrEqual(3) + }) + + it('should resolve select values to their display name', () => { + render( + <MetadataFieldList + mainField="book" + metadata={{ language: 'en' }} + />, + ) + + expect(screen.getByText('English')).toBeInTheDocument() + }) + }) + + // Verify edit mode passes correct props + describe('Edit Mode', () => { + it('should render fields in edit mode when canEdit is true', () => { + render( + <MetadataFieldList + mainField="book" + canEdit={true} + metadata={{ title: 'Book Title' }} + />, + ) + + // In edit mode, FieldInfo renders input elements + const inputs = screen.getAllByRole('textbox') + expect(inputs.length).toBeGreaterThan(0) + }) + + it('should call onFieldUpdate when a field value changes', () => { + const onUpdate = vi.fn() + render( + <MetadataFieldList + mainField="book" + canEdit={true} + metadata={{ title: '' }} + onFieldUpdate={onUpdate} + />, + ) + + // Find the first textbox and type in it + const inputs = screen.getAllByRole('textbox') + fireEvent.change(inputs[0], { target: { value: 'New Title' } }) + + expect(onUpdate).toHaveBeenCalled() + }) + }) + + // Verify fixed field types use docDetail as source + describe('Fixed Field Types', () => { + it('should use docDetail as source data for originInfo type', () => { + const docDetail = { source: 'Web', hit_count: 42, segment_count: 10 } + + render( + <MetadataFieldList + mainField="originInfo" + docDetail={docDetail as never} + metadata={{}} + />, + ) + + expect(screen.getByText('Source')).toBeInTheDocument() + expect(screen.getByText('Web')).toBeInTheDocument() + }) + + it('should render custom render function output for fields with render', () => { + const docDetail = { source: 'API', hit_count: 15, segment_count: 5 } + + render( + <MetadataFieldList + mainField="originInfo" + docDetail={docDetail as never} + metadata={{}} + />, + ) + + expect(screen.getByText('15 / 5')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/hooks/__tests__/use-metadata-state.spec.ts b/web/app/components/datasets/documents/detail/metadata/hooks/__tests__/use-metadata-state.spec.ts new file mode 100644 index 0000000000..ab1d45338f --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/hooks/__tests__/use-metadata-state.spec.ts @@ -0,0 +1,164 @@ +import type { ReactNode } from 'react' +import type { FullDocumentDetail } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { ToastContext } from '@/app/components/base/toast' + +import { useMetadataState } from '../use-metadata-state' + +const { mockNotify, mockModifyDocMetadata } = vi.hoisted(() => ({ + mockNotify: vi.fn(), + mockModifyDocMetadata: vi.fn(), +})) + +vi.mock('../../../context', () => ({ + useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) => + selector({ datasetId: 'ds-1', documentId: 'doc-1' }), +})) + +vi.mock('@/service/datasets', () => ({ + modifyDocMetadata: (...args: unknown[]) => mockModifyDocMetadata(...args), +})) + +vi.mock('@/hooks/use-metadata', () => ({ useMetadataMap: () => ({}) })) + +vi.mock('@/utils', () => ({ + asyncRunSafe: async (promise: Promise<unknown>) => { + try { + return [null, await promise] + } + catch (e) { return [e] } + }, +})) + +// Wrapper that provides ToastContext with the mock notify function +const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(ToastContext.Provider, { value: { notify: mockNotify, close: vi.fn() }, children }) + +type DocDetail = Parameters<typeof useMetadataState>[0]['docDetail'] + +const makeDoc = (overrides: Partial<FullDocumentDetail> = {}): DocDetail => + ({ doc_type: 'book', doc_metadata: { title: 'Test Book', author: 'Author' }, ...overrides } as DocDetail) + +describe('useMetadataState', () => { + // Verify all metadata editing workflows using a stable docDetail reference + it('should manage the full metadata editing lifecycle', async () => { + mockModifyDocMetadata.mockResolvedValue({ result: 'ok' }) + const onUpdate = vi.fn() + + // IMPORTANT: Create a stable reference outside the render callback + // to prevent useEffect infinite loops on docDetail?.doc_metadata + const stableDocDetail = makeDoc() + + const { result } = renderHook(() => + useMetadataState({ docDetail: stableDocDetail, onUpdate }), { wrapper }) + + // --- Initialization --- + expect(result.current.docType).toBe('book') + expect(result.current.editStatus).toBe(false) + expect(result.current.showDocTypes).toBe(false) + expect(result.current.metadataParams.documentType).toBe('book') + expect(result.current.metadataParams.metadata).toEqual({ title: 'Test Book', author: 'Author' }) + + // --- Enable editing --- + act(() => { + result.current.enableEdit() + }) + expect(result.current.editStatus).toBe(true) + + // --- Update individual field --- + act(() => { + result.current.updateMetadataField('title', 'Modified Title') + }) + expect(result.current.metadataParams.metadata.title).toBe('Modified Title') + expect(result.current.metadataParams.metadata.author).toBe('Author') + + // --- Cancel edit restores original data --- + act(() => { + result.current.cancelEdit() + }) + expect(result.current.metadataParams.metadata.title).toBe('Test Book') + expect(result.current.editStatus).toBe(false) + + // --- Doc type selection: cancel restores previous --- + act(() => { + result.current.enableEdit() + }) + act(() => { + result.current.setShowDocTypes(true) + }) + act(() => { + result.current.setTempDocType('web_page') + }) + act(() => { + result.current.cancelDocType() + }) + expect(result.current.tempDocType).toBe('book') + expect(result.current.showDocTypes).toBe(false) + + // --- Confirm different doc type clears metadata --- + act(() => { + result.current.setShowDocTypes(true) + }) + act(() => { + result.current.setTempDocType('web_page') + }) + act(() => { + result.current.confirmDocType() + }) + expect(result.current.metadataParams.documentType).toBe('web_page') + expect(result.current.metadataParams.metadata).toEqual({}) + + // --- Save succeeds --- + await act(async () => { + await result.current.saveMetadata() + }) + expect(mockModifyDocMetadata).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentId: 'doc-1', + body: { doc_type: 'web_page', doc_metadata: {} }, + }) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) + expect(onUpdate).toHaveBeenCalled() + expect(result.current.editStatus).toBe(false) + expect(result.current.saveLoading).toBe(false) + + // --- Save failure notifies error --- + mockNotify.mockClear() + mockModifyDocMetadata.mockRejectedValue(new Error('fail')) + act(() => { + result.current.enableEdit() + }) + await act(async () => { + await result.current.saveMetadata() + }) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + // Verify empty doc type starts in editing mode + it('should initialize in editing mode when no doc type exists', () => { + const stableDocDetail = makeDoc({ doc_type: '' as FullDocumentDetail['doc_type'], doc_metadata: {} as FullDocumentDetail['doc_metadata'] }) + const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper }) + + expect(result.current.docType).toBe('') + expect(result.current.editStatus).toBe(true) + expect(result.current.showDocTypes).toBe(true) + }) + + // Verify "others" normalization + it('should normalize "others" doc_type to empty string', () => { + const stableDocDetail = makeDoc({ doc_type: 'others' as FullDocumentDetail['doc_type'] }) + const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper }) + + expect(result.current.docType).toBe('') + }) + + // Verify undefined docDetail handling + it('should handle undefined docDetail gracefully', () => { + const { result } = renderHook(() => useMetadataState({ docDetail: undefined }), { wrapper }) + + expect(result.current.docType).toBe('') + expect(result.current.editStatus).toBe(true) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx index b00e430575..4ed09de462 100644 --- a/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx @@ -1,40 +1,49 @@ +import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { Query } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import QueryInput from '../index' -vi.mock('uuid', () => ({ - v4: () => 'mock-uuid', -})) - -vi.mock('@/app/components/base/button', () => ({ - default: ({ children, onClick, disabled, loading }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, loading?: boolean }) => ( - <button data-testid="submit-button" onClick={onClick} disabled={disabled || loading}> - {children} - </button> - ), -})) - +// Capture onChange callback so tests can trigger handleImageChange +let capturedOnChange: ((files: FileEntity[]) => void) | null = null vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ - default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => ( - <div data-testid="image-uploader"> - {textArea} - {actionButton} - </div> - ), + default: ({ textArea, actionButton, onChange }: { textArea: React.ReactNode, actionButton: React.ReactNode, onChange?: (files: FileEntity[]) => void }) => { + capturedOnChange = onChange ?? null + return ( + <div data-testid="image-uploader"> + {textArea} + {actionButton} + </div> + ) + }, })) vi.mock('@/app/components/datasets/common/retrieval-method-info', () => ({ getIcon: () => '/test-icon.png', })) +// Capture onSave callback for external retrieval modal +let _capturedModalOnSave: ((data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void) | null = null vi.mock('@/app/components/datasets/hit-testing/modify-external-retrieval-modal', () => ({ - default: () => <div data-testid="external-retrieval-modal" />, + default: ({ onSave, onClose }: { onSave: (data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void, onClose: () => void }) => { + _capturedModalOnSave = onSave + return ( + <div data-testid="external-retrieval-modal"> + <button data-testid="modal-save" onClick={() => onSave({ top_k: 10, score_threshold: 0.8, score_threshold_enabled: true })}>Save</button> + <button data-testid="modal-close" onClick={onClose}>Close</button> + </div> + ) + }, })) +// Capture handleTextChange callback +let _capturedHandleTextChange: ((e: React.ChangeEvent<HTMLTextAreaElement>) => void) | null = null vi.mock('../textarea', () => ({ - default: ({ text }: { text: string }) => <textarea data-testid="textarea" defaultValue={text} />, + default: ({ text, handleTextChange }: { text: string, handleTextChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void }) => { + _capturedHandleTextChange = handleTextChange + return <textarea data-testid="textarea" defaultValue={text} onChange={handleTextChange} /> + }, })) vi.mock('@/context/dataset-detail', () => ({ @@ -42,7 +51,8 @@ vi.mock('@/context/dataset-detail', () => ({ })) describe('QueryInput', () => { - const defaultProps = { + // Re-create per test to avoid cross-test mutation (handleTextChange mutates query objects) + const makeDefaultProps = () => ({ onUpdateList: vi.fn(), setHitResult: vi.fn(), setExternalHitResult: vi.fn(), @@ -55,10 +65,16 @@ describe('QueryInput', () => { isEconomy: false, hitTestingMutation: vi.fn(), externalKnowledgeBaseHitTestingMutation: vi.fn(), - } + }) + + let defaultProps: ReturnType<typeof makeDefaultProps> beforeEach(() => { vi.clearAllMocks() + defaultProps = makeDefaultProps() + capturedOnChange = null + _capturedModalOnSave = null + _capturedHandleTextChange = null }) it('should render title', () => { @@ -73,7 +89,7 @@ describe('QueryInput', () => { it('should render submit button', () => { render(<QueryInput {...defaultProps} />) - expect(screen.getByTestId('submit-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /input\.testing/ })).toBeInTheDocument() }) it('should disable submit button when text is empty', () => { @@ -82,7 +98,7 @@ describe('QueryInput', () => { queries: [{ content: '', content_type: 'text_query', file_info: null }] satisfies Query[], } render(<QueryInput {...props} />) - expect(screen.getByTestId('submit-button')).toBeDisabled() + expect(screen.getByRole('button', { name: /input\.testing/ })).toBeDisabled() }) it('should render retrieval method for non-external mode', () => { @@ -101,11 +117,302 @@ describe('QueryInput', () => { queries: [{ content: 'a'.repeat(201), content_type: 'text_query', file_info: null }] satisfies Query[], } render(<QueryInput {...props} />) - expect(screen.getByTestId('submit-button')).toBeDisabled() + expect(screen.getByRole('button', { name: /input\.testing/ })).toBeDisabled() }) - it('should disable submit button when loading', () => { + it('should show loading state on submit button when loading', () => { render(<QueryInput {...defaultProps} loading={true} />) - expect(screen.getByTestId('submit-button')).toBeDisabled() + const submitButton = screen.getByRole('button', { name: /input\.testing/ }) + // The real Button component does not disable on loading; it shows a spinner + expect(submitButton).toBeInTheDocument() + expect(submitButton.querySelector('[role="status"]')).toBeInTheDocument() + }) + + // Cover line 83: images useMemo with image_query data + describe('Image Queries', () => { + it('should parse image_query entries from queries', () => { + const queries: Query[] = [ + { content: 'test', content_type: 'text_query', file_info: null }, + { + content: 'https://img.example.com/1.png', + content_type: 'image_query', + file_info: { id: 'img-1', name: 'photo.png', size: 1024, mime_type: 'image/png', extension: 'png', source_url: 'https://img.example.com/1.png' }, + }, + ] + render(<QueryInput {...defaultProps} queries={queries} />) + + // Submit should be enabled since we have text + uploaded image + expect(screen.getByRole('button', { name: /input\.testing/ })).not.toBeDisabled() + }) + }) + + // Cover lines 106-107: handleSaveExternalRetrievalSettings + describe('External Retrieval Settings', () => { + it('should open and close external retrieval modal', () => { + render(<QueryInput {...defaultProps} isExternal={true} />) + + // Click settings button to open modal + fireEvent.click(screen.getByRole('button', { name: /settingTitle/ })) + expect(screen.getByTestId('external-retrieval-modal')).toBeInTheDocument() + + // Close modal + fireEvent.click(screen.getByTestId('modal-close')) + expect(screen.queryByTestId('external-retrieval-modal')).not.toBeInTheDocument() + }) + + it('should save external retrieval settings and close modal', () => { + render(<QueryInput {...defaultProps} isExternal={true} />) + + // Open modal + fireEvent.click(screen.getByRole('button', { name: /settingTitle/ })) + expect(screen.getByTestId('external-retrieval-modal')).toBeInTheDocument() + + // Save settings + fireEvent.click(screen.getByTestId('modal-save')) + expect(screen.queryByTestId('external-retrieval-modal')).not.toBeInTheDocument() + }) + }) + + // Cover line 121: handleTextChange when textQuery already exists + describe('Text Change Handling', () => { + it('should update existing text query on text change', () => { + render(<QueryInput {...defaultProps} />) + + const textarea = screen.getByTestId('textarea') + fireEvent.change(textarea, { target: { value: 'updated text' } }) + + expect(defaultProps.setQueries).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ content: 'updated text', content_type: 'text_query' }), + ]), + ) + }) + + it('should create new text query when none exists', () => { + render(<QueryInput {...defaultProps} queries={[]} />) + + const textarea = screen.getByTestId('textarea') + fireEvent.change(textarea, { target: { value: 'new text' } }) + + expect(defaultProps.setQueries).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ content: 'new text', content_type: 'text_query' }), + ]), + ) + }) + }) + + // Cover lines 127-143: handleImageChange + describe('Image Change Handling', () => { + it('should update queries when images change', () => { + render(<QueryInput {...defaultProps} />) + + const files: FileEntity[] = [{ + id: 'f-1', + name: 'pic.jpg', + size: 2048, + mimeType: 'image/jpeg', + extension: 'jpg', + sourceUrl: 'https://img.example.com/pic.jpg', + uploadedId: 'uploaded-1', + progress: 100, + }] + + capturedOnChange?.(files) + + expect(defaultProps.setQueries).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ content_type: 'text_query' }), + expect.objectContaining({ + content: 'https://img.example.com/pic.jpg', + content_type: 'image_query', + file_info: expect.objectContaining({ id: 'uploaded-1', name: 'pic.jpg' }), + }), + ]), + ) + }) + + it('should handle files with missing sourceUrl and uploadedId', () => { + render(<QueryInput {...defaultProps} />) + + const files: FileEntity[] = [{ + id: 'f-2', + name: 'no-url.jpg', + size: 512, + mimeType: 'image/jpeg', + extension: 'jpg', + progress: 100, + // sourceUrl and uploadedId are undefined + }] + + capturedOnChange?.(files) + + expect(defaultProps.setQueries).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + content: '', + content_type: 'image_query', + file_info: expect.objectContaining({ id: '', source_url: '' }), + }), + ]), + ) + }) + + it('should replace all existing image queries with new ones', () => { + const queries: Query[] = [ + { content: 'text', content_type: 'text_query', file_info: null }, + { content: 'old-img', content_type: 'image_query', file_info: { id: 'old', name: 'old.png', size: 100, mime_type: 'image/png', extension: 'png', source_url: '' } }, + ] + render(<QueryInput {...defaultProps} queries={queries} />) + + capturedOnChange?.([]) + + // Should keep text query but remove all image queries + expect(defaultProps.setQueries).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ content_type: 'text_query' }), + ]), + ) + // Should not contain image_query + const calledWith = defaultProps.setQueries.mock.calls[0][0] as Query[] + expect(calledWith.filter(q => q.content_type === 'image_query')).toHaveLength(0) + }) + }) + + // Cover lines 146-162: onSubmit (hit testing mutation) + describe('Submit Handlers', () => { + it('should call hitTestingMutation on submit for non-external mode', async () => { + const mockMutation = vi.fn(async (_req, opts) => { + const response = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] } + opts?.onSuccess?.(response) + return response + }) + + render(<QueryInput {...defaultProps} hitTestingMutation={mockMutation} />) + + fireEvent.click(screen.getByRole('button', { name: /input\.testing/ })) + + await waitFor(() => { + expect(mockMutation).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'test query', + retrieval_model: expect.objectContaining({ search_method: 'semantic_search' }), + }), + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) + }) + expect(defaultProps.setHitResult).toHaveBeenCalled() + expect(defaultProps.onUpdateList).toHaveBeenCalled() + }) + + it('should call onSubmit callback after successful hit testing', async () => { + const mockOnSubmit = vi.fn() + const mockMutation = vi.fn(async (_req, opts) => { + const response = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] } + opts?.onSuccess?.(response) + return response + }) + + render(<QueryInput {...defaultProps} hitTestingMutation={mockMutation} onSubmit={mockOnSubmit} />) + + fireEvent.click(screen.getByRole('button', { name: /input\.testing/ })) + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalled() + }) + }) + + it('should use keywordSearch when isEconomy is true', async () => { + const mockResponse = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] } + const mockMutation = vi.fn(async (_req, opts) => { + opts?.onSuccess?.(mockResponse) + return mockResponse + }) + + render(<QueryInput {...defaultProps} hitTestingMutation={mockMutation} isEconomy={true} />) + + fireEvent.click(screen.getByRole('button', { name: /input\.testing/ })) + + await waitFor(() => { + expect(mockMutation).toHaveBeenCalledWith( + expect.objectContaining({ + retrieval_model: expect.objectContaining({ search_method: 'keyword_search' }), + }), + expect.anything(), + ) + }) + }) + + // Cover lines 164-178: externalRetrievalTestingOnSubmit + it('should call externalKnowledgeBaseHitTestingMutation for external mode', async () => { + const mockExternalMutation = vi.fn(async (_req, opts) => { + const response = { query: { content: '' }, records: [] } + opts?.onSuccess?.(response) + return response + }) + + render(<QueryInput {...defaultProps} isExternal={true} externalKnowledgeBaseHitTestingMutation={mockExternalMutation} />) + + fireEvent.click(screen.getByRole('button', { name: /input\.testing/ })) + + await waitFor(() => { + expect(mockExternalMutation).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'test query', + external_retrieval_model: expect.objectContaining({ + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }), + }), + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) + }) + expect(defaultProps.setExternalHitResult).toHaveBeenCalled() + expect(defaultProps.onUpdateList).toHaveBeenCalled() + }) + + it('should include image attachment_ids in submit request', async () => { + const queries: Query[] = [ + { content: 'test', content_type: 'text_query', file_info: null }, + { content: 'img-url', content_type: 'image_query', file_info: { id: 'img-id', name: 'pic.png', size: 100, mime_type: 'image/png', extension: 'png', source_url: 'img-url' } }, + ] + const mockResponse = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] } + const mockMutation = vi.fn(async (_req, opts) => { + opts?.onSuccess?.(mockResponse) + return mockResponse + }) + + render(<QueryInput {...defaultProps} queries={queries} hitTestingMutation={mockMutation} />) + + fireEvent.click(screen.getByRole('button', { name: /input\.testing/ })) + + await waitFor(() => { + expect(mockMutation).toHaveBeenCalledWith( + expect.objectContaining({ + // uploadedId is mapped from file_info.id + attachment_ids: expect.arrayContaining(['img-id']), + }), + expect.anything(), + ) + }) + }) + }) + + // Cover lines 217-238: retrieval method click handler + describe('Retrieval Method', () => { + it('should call onClickRetrievalMethod when retrieval method is clicked', () => { + render(<QueryInput {...defaultProps} />) + + fireEvent.click(screen.getByText('dataset.retrieval.semantic_search.title')) + + expect(defaultProps.onClickRetrievalMethod).toHaveBeenCalled() + }) + + it('should show keyword_search when isEconomy is true', () => { + render(<QueryInput {...defaultProps} isEconomy={true} />) + + expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/develop/__tests__/code.spec.tsx b/web/app/components/develop/__tests__/code.spec.tsx index 0b57a54294..2614be704d 100644 --- a/web/app/components/develop/__tests__/code.spec.tsx +++ b/web/app/components/develop/__tests__/code.spec.tsx @@ -6,10 +6,15 @@ vi.mock('@/utils/clipboard', () => ({ writeTextToClipboard: vi.fn().mockResolvedValue(undefined), })) +// Suppress expected React act() warnings and jsdom unimplemented API errors +vi.spyOn(console, 'error').mockImplementation(() => {}) + describe('code.tsx components', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers({ shouldAdvanceTime: true }) + // jsdom does not implement scrollBy; mock it to prevent stderr noise + window.scrollBy = vi.fn() }) afterEach(() => { @@ -18,14 +23,9 @@ describe('code.tsx components', () => { }) describe('Code', () => { - it('should render children', () => { + it('should render children as a code element', () => { render(<Code>const x = 1</Code>) - expect(screen.getByText('const x = 1')).toBeInTheDocument() - }) - - it('should render as code element', () => { - render(<Code>code snippet</Code>) - const codeElement = screen.getByText('code snippet') + const codeElement = screen.getByText('const x = 1') expect(codeElement.tagName).toBe('CODE') }) @@ -48,14 +48,9 @@ describe('code.tsx components', () => { }) describe('Embed', () => { - it('should render value prop', () => { + it('should render value prop as a span element', () => { render(<Embed value="embedded content">ignored children</Embed>) - expect(screen.getByText('embedded content')).toBeInTheDocument() - }) - - it('should render as span element', () => { - render(<Embed value="test value">children</Embed>) - const span = screen.getByText('test value') + const span = screen.getByText('embedded content') expect(span.tagName).toBe('SPAN') }) @@ -65,7 +60,7 @@ describe('code.tsx components', () => { expect(embed).toHaveClass('embed-class') }) - it('should not render children, only value', () => { + it('should render only value, not children', () => { render(<Embed value="shown">hidden children</Embed>) expect(screen.getByText('shown')).toBeInTheDocument() expect(screen.queryByText('hidden children')).not.toBeInTheDocument() @@ -82,27 +77,6 @@ describe('code.tsx components', () => { ) expect(screen.getByText('const hello = \'world\'')).toBeInTheDocument() }) - - it('should have shadow and rounded styles', () => { - const { container } = render( - <CodeGroup targetCode="code here"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const codeGroup = container.querySelector('.shadow-md') - expect(codeGroup).toBeInTheDocument() - expect(codeGroup).toHaveClass('rounded-2xl') - }) - - it('should have bg-zinc-900 background', () => { - const { container } = render( - <CodeGroup targetCode="code"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const codeGroup = container.querySelector('.bg-zinc-900') - expect(codeGroup).toBeInTheDocument() - }) }) describe('with array targetCode', () => { @@ -184,23 +158,14 @@ describe('code.tsx components', () => { }) describe('with title prop', () => { - it('should render title in header', () => { + it('should render title in an h3 heading', () => { render( <CodeGroup title="API Example" targetCode="code"> <pre><code>fallback</code></pre> </CodeGroup>, ) - expect(screen.getByText('API Example')).toBeInTheDocument() - }) - - it('should render title in h3 element', () => { - render( - <CodeGroup title="Example Title" targetCode="code"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) const h3 = screen.getByRole('heading', { level: 3 }) - expect(h3).toHaveTextContent('Example Title') + expect(h3).toHaveTextContent('API Example') }) }) @@ -223,30 +188,18 @@ describe('code.tsx components', () => { expect(screen.getByText('/api/users')).toBeInTheDocument() }) - it('should render both tag and label with separator', () => { - const { container } = render( + it('should render both tag and label together', () => { + render( <CodeGroup tag="POST" label="/api/create" targetCode="code"> <pre><code>fallback</code></pre> </CodeGroup>, ) expect(screen.getByText('POST')).toBeInTheDocument() expect(screen.getByText('/api/create')).toBeInTheDocument() - const separator = container.querySelector('.rounded-full.bg-zinc-500') - expect(separator).toBeInTheDocument() }) }) describe('CopyButton functionality', () => { - it('should render copy button', () => { - render( - <CodeGroup targetCode="copyable code"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const copyButton = screen.getByRole('button') - expect(copyButton).toBeInTheDocument() - }) - it('should show "Copy" text initially', () => { render( <CodeGroup targetCode="code"> @@ -322,88 +275,32 @@ describe('code.tsx components', () => { expect(screen.getByText('child code content')).toBeInTheDocument() }) }) - - describe('styling', () => { - it('should have not-prose class to prevent prose styling', () => { - const { container } = render( - <CodeGroup targetCode="code"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const codeGroup = container.querySelector('.not-prose') - expect(codeGroup).toBeInTheDocument() - }) - - it('should have my-6 margin', () => { - const { container } = render( - <CodeGroup targetCode="code"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const codeGroup = container.querySelector('.my-6') - expect(codeGroup).toBeInTheDocument() - }) - - it('should have overflow-hidden', () => { - const { container } = render( - <CodeGroup targetCode="code"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const codeGroup = container.querySelector('.overflow-hidden') - expect(codeGroup).toBeInTheDocument() - }) - }) }) describe('Pre', () => { - describe('when outside CodeGroup context', () => { - it('should wrap children in CodeGroup', () => { - const { container } = render( - <Pre> - <pre><code>code content</code></pre> - </Pre>, - ) - const codeGroup = container.querySelector('.bg-zinc-900') - expect(codeGroup).toBeInTheDocument() - }) - - it('should pass props to CodeGroup', () => { - render( - <Pre title="Pre Title"> - <pre><code>code</code></pre> - </Pre>, - ) - expect(screen.getByText('Pre Title')).toBeInTheDocument() - }) + it('should wrap children in CodeGroup when outside CodeGroup context', () => { + render( + <Pre title="Pre Title"> + <pre><code>code</code></pre> + </Pre>, + ) + expect(screen.getByText('Pre Title')).toBeInTheDocument() }) - describe('when inside CodeGroup context (isGrouped)', () => { - it('should return children directly without wrapping', () => { - render( - <CodeGroup targetCode="outer code"> - <Pre> - <code>inner code</code> - </Pre> - </CodeGroup>, - ) - expect(screen.getByText('outer code')).toBeInTheDocument() - }) + it('should return children directly when inside CodeGroup context', () => { + render( + <CodeGroup targetCode="outer code"> + <Pre> + <code>inner code</code> + </Pre> + </CodeGroup>, + ) + expect(screen.getByText('outer code')).toBeInTheDocument() }) }) describe('CodePanelHeader (via CodeGroup)', () => { - it('should not render when neither tag nor label provided', () => { - const { container } = render( - <CodeGroup targetCode="code"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const headerDivider = container.querySelector('.border-b-white\\/7\\.5') - expect(headerDivider).not.toBeInTheDocument() - }) - - it('should render when only tag is provided', () => { + it('should render when tag is provided', () => { render( <CodeGroup tag="GET" targetCode="code"> <pre><code>fallback</code></pre> @@ -412,7 +309,7 @@ describe('code.tsx components', () => { expect(screen.getByText('GET')).toBeInTheDocument() }) - it('should render when only label is provided', () => { + it('should render when label is provided', () => { render( <CodeGroup label="/api/endpoint" targetCode="code"> <pre><code>fallback</code></pre> @@ -420,17 +317,6 @@ describe('code.tsx components', () => { ) expect(screen.getByText('/api/endpoint')).toBeInTheDocument() }) - - it('should render label with font-mono styling', () => { - render( - <CodeGroup label="/api/test" targetCode="code"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const label = screen.getByText('/api/test') - expect(label.className).toContain('font-mono') - expect(label.className).toContain('text-xs') - }) }) describe('CodeGroupHeader (via CodeGroup with multiple tabs)', () => { @@ -446,39 +332,10 @@ describe('code.tsx components', () => { ) expect(screen.getByRole('tablist')).toBeInTheDocument() }) - - it('should style active tab differently', () => { - const examples = [ - { title: 'Active', code: 'active code' }, - { title: 'Inactive', code: 'inactive code' }, - ] - render( - <CodeGroup targetCode={examples}> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const activeTab = screen.getByRole('tab', { name: 'Active' }) - expect(activeTab.className).toContain('border-emerald-500') - expect(activeTab.className).toContain('text-emerald-400') - }) - - it('should have header background styling', () => { - const examples = [ - { title: 'Tab1', code: 'code1' }, - { title: 'Tab2', code: 'code2' }, - ] - const { container } = render( - <CodeGroup targetCode={examples}> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const header = container.querySelector('.bg-zinc-800') - expect(header).toBeInTheDocument() - }) }) describe('CodePanel (via CodeGroup)', () => { - it('should render code in pre element', () => { + it('should render code in a pre element', () => { render( <CodeGroup targetCode="pre content"> <pre><code>fallback</code></pre> @@ -487,50 +344,10 @@ describe('code.tsx components', () => { const preElement = screen.getByText('pre content').closest('pre') expect(preElement).toBeInTheDocument() }) - - it('should have text-white class on pre', () => { - render( - <CodeGroup targetCode="white text"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const preElement = screen.getByText('white text').closest('pre') - expect(preElement?.className).toContain('text-white') - }) - - it('should have text-xs class on pre', () => { - render( - <CodeGroup targetCode="small text"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const preElement = screen.getByText('small text').closest('pre') - expect(preElement?.className).toContain('text-xs') - }) - - it('should have overflow-x-auto on pre', () => { - render( - <CodeGroup targetCode="scrollable"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const preElement = screen.getByText('scrollable').closest('pre') - expect(preElement?.className).toContain('overflow-x-auto') - }) - - it('should have p-4 padding on pre', () => { - render( - <CodeGroup targetCode="padded"> - <pre><code>fallback</code></pre> - </CodeGroup>, - ) - const preElement = screen.getByText('padded').closest('pre') - expect(preElement?.className).toContain('p-4') - }) }) - describe('ClipboardIcon (via CopyButton in CodeGroup)', () => { - it('should render clipboard icon in copy button', () => { + describe('ClipboardIcon (via CopyButton)', () => { + it('should render clipboard SVG icon in copy button', () => { render( <CodeGroup targetCode="code"> <pre><code>fallback</code></pre> @@ -543,7 +360,7 @@ describe('code.tsx components', () => { }) }) - describe('edge cases', () => { + describe('Edge Cases', () => { it('should handle empty string targetCode', () => { render( <CodeGroup targetCode=""> diff --git a/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx index e022faffc1..36a577c98a 100644 --- a/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx @@ -1,11 +1,9 @@ import { act, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import copy from 'copy-to-clipboard' import InputCopy from '../input-copy' -vi.mock('copy-to-clipboard', () => ({ - default: vi.fn().mockReturnValue(true), -})) +// Suppress expected React act() warnings from CopyFeedback timer-based state updates +vi.spyOn(console, 'error').mockImplementation(() => {}) async function renderAndFlush(ui: React.ReactElement) { const result = render(ui) @@ -15,10 +13,14 @@ async function renderAndFlush(ui: React.ReactElement) { return result } +const execCommandMock = vi.fn().mockReturnValue(true) + describe('InputCopy', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers({ shouldAdvanceTime: true }) + execCommandMock.mockReturnValue(true) + document.execCommand = execCommandMock }) afterEach(() => { @@ -107,7 +109,7 @@ describe('InputCopy', () => { await user.click(copyableArea) }) - expect(copy).toHaveBeenCalledWith('copy-this-value') + expect(execCommandMock).toHaveBeenCalledWith('copy') }) it('should update copied state after clicking', async () => { @@ -119,7 +121,7 @@ describe('InputCopy', () => { await user.click(copyableArea) }) - expect(copy).toHaveBeenCalledWith('test-value') + expect(execCommandMock).toHaveBeenCalledWith('copy') }) it('should reset copied state after timeout', async () => { @@ -131,7 +133,7 @@ describe('InputCopy', () => { await user.click(copyableArea) }) - expect(copy).toHaveBeenCalledWith('test-value') + expect(execCommandMock).toHaveBeenCalledWith('copy') await act(async () => { vi.advanceTimersByTime(1500) @@ -306,7 +308,7 @@ describe('InputCopy', () => { await user.click(copyableArea) }) - expect(copy).toHaveBeenCalledTimes(3) + expect(execCommandMock).toHaveBeenCalledTimes(3) }) }) }) diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx index 8cfd976a95..a5c6d4be99 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx @@ -3,6 +3,9 @@ import userEvent from '@testing-library/user-event' import { afterEach } from 'vitest' import SecretKeyModal from '../secret-key-modal' +// Suppress expected React act() warnings from Headless UI Dialog transitions and async API state updates +vi.spyOn(console, 'error').mockImplementation(() => {}) + async function renderModal(ui: React.ReactElement) { const result = render(ui) await act(async () => { diff --git a/web/app/components/explore/__tests__/category.spec.tsx b/web/app/components/explore/__tests__/category.spec.tsx index 33349204d0..f99b28da71 100644 --- a/web/app/components/explore/__tests__/category.spec.tsx +++ b/web/app/components/explore/__tests__/category.spec.tsx @@ -60,5 +60,11 @@ describe('Category', () => { const allCategoriesItem = screen.getByText('explore.apps.allCategories') expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active') }) + + it('should render raw category name when i18n key does not exist', () => { + renderComponent({ list: ['CustomCategory', 'Recommended'] as AppCategory[] }) + + expect(screen.getByText('CustomCategory')).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/explore/__tests__/index.spec.tsx b/web/app/components/explore/__tests__/index.spec.tsx index b7ba9eccd2..b84b168333 100644 --- a/web/app/components/explore/__tests__/index.spec.tsx +++ b/web/app/components/explore/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import type { Mock } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import type { CurrentTryAppParams } from '@/context/explore-context' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { useContext } from 'use-context-selector' import { useAppContext } from '@/context/app-context' import ExploreContext from '@/context/explore-context' @@ -55,9 +56,21 @@ vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) -const ContextReader = () => { - const { hasEditPermission } = useContext(ExploreContext) - return <div>{hasEditPermission ? 'edit-yes' : 'edit-no'}</div> +const ContextReader = ({ triggerTryPanel }: { triggerTryPanel?: boolean }) => { + const { hasEditPermission, setShowTryAppPanel, isShowTryAppPanel, currentApp } = useContext(ExploreContext) + return ( + <div> + {hasEditPermission ? 'edit-yes' : 'edit-no'} + {isShowTryAppPanel && <span data-testid="try-panel-open">open</span>} + {currentApp && <span data-testid="current-app">{currentApp.appId}</span>} + {triggerTryPanel && ( + <> + <button data-testid="show-try" onClick={() => setShowTryAppPanel(true, { appId: 'test-app' } as CurrentTryAppParams)}>show</button> + <button data-testid="hide-try" onClick={() => setShowTryAppPanel(false)}>hide</button> + </> + )} + </div> + ) } describe('Explore', () => { @@ -123,5 +136,69 @@ describe('Explore', () => { expect(mockReplace).toHaveBeenCalledWith('/datasets') }) }) + + it('should skip permission check when membersData has no accounts', () => { + ; (useAppContext as Mock).mockReturnValue({ + userProfile: { id: 'user-1' }, + isCurrentWorkspaceDatasetOperator: false, + }); + (useMembers as Mock).mockReturnValue({ data: undefined }) + + render(( + <Explore> + <ContextReader /> + </Explore> + )) + + expect(screen.getByText('edit-no')).toBeInTheDocument() + }) + }) + + describe('Context: setShowTryAppPanel', () => { + it('should set currentApp params when showing try panel', async () => { + ; (useAppContext as Mock).mockReturnValue({ + userProfile: { id: 'user-1' }, + isCurrentWorkspaceDatasetOperator: false, + }); + (useMembers as Mock).mockReturnValue({ data: { accounts: [] } }) + + render(( + <Explore> + <ContextReader triggerTryPanel /> + </Explore> + )) + + fireEvent.click(screen.getByTestId('show-try')) + + await waitFor(() => { + expect(screen.getByTestId('try-panel-open')).toBeInTheDocument() + expect(screen.getByTestId('current-app')).toHaveTextContent('test-app') + }) + }) + + it('should clear currentApp params when hiding try panel', async () => { + ; (useAppContext as Mock).mockReturnValue({ + userProfile: { id: 'user-1' }, + isCurrentWorkspaceDatasetOperator: false, + }); + (useMembers as Mock).mockReturnValue({ data: { accounts: [] } }) + + render(( + <Explore> + <ContextReader triggerTryPanel /> + </Explore> + )) + + fireEvent.click(screen.getByTestId('show-try')) + await waitFor(() => { + expect(screen.getByTestId('try-panel-open')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('hide-try')) + await waitFor(() => { + expect(screen.queryByTestId('try-panel-open')).not.toBeInTheDocument() + expect(screen.queryByTestId('current-app')).not.toBeInTheDocument() + }) + }) }) }) diff --git a/web/app/components/explore/app-card/__tests__/index.spec.tsx b/web/app/components/explore/app-card/__tests__/index.spec.tsx index f5bb5e9615..8bc0fa99d2 100644 --- a/web/app/components/explore/app-card/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-card/__tests__/index.spec.tsx @@ -2,6 +2,7 @@ import type { AppCardProps } from '../index' import type { App } from '@/models/explore' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' +import ExploreContext from '@/context/explore-context' import { AppModeEnum } from '@/types/app' import AppCard from '../index' @@ -136,5 +137,32 @@ describe('AppCard', () => { expect(screen.getByText('Sample App')).toBeInTheDocument() }) + + it('should call setShowTryAppPanel when try button is clicked', () => { + const mockSetShowTryAppPanel = vi.fn() + const app = createApp() + + render( + <ExploreContext.Provider + value={{ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission: false, + installedApps: [], + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: false, + setShowTryAppPanel: mockSetShowTryAppPanel, + }} + > + <AppCard app={app} canCreate={true} onCreate={vi.fn()} isExplore={true} /> + </ExploreContext.Provider>, + ) + + fireEvent.click(screen.getByText('explore.appCard.try')) + + expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, { appId: 'app-id', app }) + }) }) }) diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index cb83fd3147..5048468b46 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -1,44 +1,21 @@ import type { Mock } from 'vitest' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' +import type { CurrentTryAppParams } from '@/context/explore-context' import type { App } from '@/models/explore' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import ExploreContext from '@/context/explore-context' +import { useGlobalPublicStore } from '@/context/global-public-context' import { fetchAppDetail } from '@/service/explore' import { AppModeEnum } from '@/types/app' import AppList from '../index' -const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}' -let mockTabValue = allCategoriesEn -const mockSetTab = vi.fn() let mockExploreData: { categories: string[], allList: App[] } | undefined = { categories: [], allList: [] } let mockIsLoading = false let mockIsError = false const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal<typeof import('nuqs')>() - return { - ...actual, - useQueryState: () => [mockTabValue, mockSetTab], - } -}) - -vi.mock('ahooks', async () => { - const actual = await vi.importActual<typeof import('ahooks')>('ahooks') - const React = await vi.importActual<typeof import('react')>('react') - return { - ...actual, - useDebounceFn: (fn: (...args: unknown[]) => void) => { - const fnRef = React.useRef(fn) - fnRef.current = fn - return { - run: () => setTimeout(() => fnRef.current(), 0), - } - }, - } -}) - vi.mock('@/service/use-explore', () => ({ useExploreAppList: () => ({ data: mockExploreData, @@ -85,6 +62,19 @@ vi.mock('@/app/components/explore/create-app-modal', () => ({ }, })) +vi.mock('../../try-app', () => ({ + default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => ( + <div data-testid="try-app-panel"> + <button data-testid="try-app-create" onClick={onCreate}>create</button> + <button data-testid="try-app-close" onClick={onClose}>close</button> + </div> + ), +})) + +vi.mock('../../banner/banner', () => ({ + default: () => <div data-testid="explore-banner">banner</div>, +})) + vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({ default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( <div data-testid="dsl-confirm-modal"> @@ -121,35 +111,41 @@ const createApp = (overrides: Partial<App> = {}): App => ({ is_agent: overrides.is_agent ?? false, }) -const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => { +const renderWithContext = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => { return render( - <ExploreContext.Provider - value={{ - controlUpdateInstalledApps: 0, - setControlUpdateInstalledApps: vi.fn(), - hasEditPermission, - installedApps: [], - setInstalledApps: vi.fn(), - isFetchingInstalledApps: false, - setIsFetchingInstalledApps: vi.fn(), - isShowTryAppPanel: false, - setShowTryAppPanel: vi.fn(), - }} - > - <AppList onSuccess={onSuccess} /> - </ExploreContext.Provider>, + <NuqsTestingAdapter searchParams={searchParams}> + <ExploreContext.Provider + value={{ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission, + installedApps: [], + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: false, + setShowTryAppPanel: vi.fn(), + }} + > + <AppList onSuccess={onSuccess} /> + </ExploreContext.Provider> + </NuqsTestingAdapter>, ) } describe('AppList', () => { beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() - mockTabValue = allCategoriesEn mockExploreData = { categories: [], allList: [] } mockIsLoading = false mockIsError = false }) + afterEach(() => { + vi.useRealTimers() + }) + describe('Rendering', () => { it('should render loading when the query is loading', () => { mockExploreData = undefined @@ -175,13 +171,12 @@ describe('AppList', () => { describe('Props', () => { it('should filter apps by selected category', () => { - mockTabValue = 'Writing' mockExploreData = { categories: ['Writing', 'Translate'], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], } - renderWithContext() + renderWithContext(false, undefined, { category: 'Writing' }) expect(screen.getByText('Alpha')).toBeInTheDocument() expect(screen.queryByText('Beta')).not.toBeInTheDocument() @@ -199,13 +194,16 @@ describe('AppList', () => { const input = screen.getByPlaceholderText('common.operation.search') fireEvent.change(input, { target: { value: 'gam' } }) - await waitFor(() => { - expect(screen.queryByText('Alpha')).not.toBeInTheDocument() - expect(screen.getByText('Gamma')).toBeInTheDocument() + await act(async () => { + await vi.advanceTimersByTimeAsync(500) }) + + expect(screen.queryByText('Alpha')).not.toBeInTheDocument() + expect(screen.getByText('Gamma')).toBeInTheDocument() }) it('should handle create flow and confirm DSL when pending', async () => { + vi.useRealTimers() const onSuccess = vi.fn() mockExploreData = { categories: ['Writing'], @@ -247,16 +245,241 @@ describe('AppList', () => { const input = screen.getByPlaceholderText('common.operation.search') fireEvent.change(input, { target: { value: 'gam' } }) - await waitFor(() => { - expect(screen.queryByText('Alpha')).not.toBeInTheDocument() + await act(async () => { + await vi.advanceTimersByTimeAsync(500) }) + expect(screen.queryByText('Alpha')).not.toBeInTheDocument() fireEvent.click(screen.getByTestId('input-clear')) + await act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) + + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('Gamma')).toBeInTheDocument() + }) + + it('should render nothing when isError is true', () => { + mockIsError = true + mockExploreData = undefined + + const { container } = renderWithContext() + + expect(container.innerHTML).toBe('') + }) + + it('should render nothing when data is undefined', () => { + mockExploreData = undefined + + const { container } = renderWithContext() + + expect(container.innerHTML).toBe('') + }) + + it('should reset filter when reset button is clicked', async () => { + mockExploreData = { + categories: ['Writing'], + allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], + } + renderWithContext() + + const input = screen.getByPlaceholderText('common.operation.search') + fireEvent.change(input, { target: { value: 'gam' } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) + expect(screen.queryByText('Alpha')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('explore.apps.resetFilter')) + + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('Gamma')).toBeInTheDocument() + }) + + it('should close create modal via hide button', async () => { + vi.useRealTimers() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + }; + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + + renderWithContext(true) + fireEvent.click(screen.getByText('explore.appCard.addToWorkspace')) + expect(await screen.findByTestId('create-app-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('hide-create')) + await waitFor(() => { + expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() + }) + }) + + it('should close create modal on successful DSL import', async () => { + vi.useRealTimers() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + }; + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + + renderWithContext(true) + fireEvent.click(screen.getByText('explore.appCard.addToWorkspace')) + fireEvent.click(await screen.findByTestId('confirm-create')) await waitFor(() => { - expect(screen.getByText('Alpha')).toBeInTheDocument() - expect(screen.getByText('Gamma')).toBeInTheDocument() + expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() + }) + }) + + it('should cancel DSL confirm modal', async () => { + vi.useRealTimers() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + }; + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }) + + renderWithContext(true) + fireEvent.click(screen.getByText('explore.appCard.addToWorkspace')) + fireEvent.click(await screen.findByTestId('confirm-create')) + + await waitFor(() => { + expect(screen.getByTestId('dsl-confirm-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('dsl-cancel')) + await waitFor(() => { + expect(screen.queryByTestId('dsl-confirm-modal')).not.toBeInTheDocument() }) }) }) + + describe('TryApp Panel', () => { + it('should open create modal from try app panel', async () => { + vi.useRealTimers() + const mockSetShowTryAppPanel = vi.fn() + const app = createApp() + mockExploreData = { + categories: ['Writing'], + allList: [app], + } + + render( + <NuqsTestingAdapter> + <ExploreContext.Provider + value={{ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission: true, + installedApps: [], + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: true, + setShowTryAppPanel: mockSetShowTryAppPanel, + currentApp: { appId: 'app-1', app }, + }} + > + <AppList /> + </ExploreContext.Provider> + </NuqsTestingAdapter>, + ) + + const createBtn = screen.getByTestId('try-app-create') + fireEvent.click(createBtn) + + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + }) + }) + + it('should open create modal with null currApp when appParams has no app', async () => { + vi.useRealTimers() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + + render( + <NuqsTestingAdapter> + <ExploreContext.Provider + value={{ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission: true, + installedApps: [], + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: true, + setShowTryAppPanel: vi.fn(), + currentApp: { appId: 'app-1' } as CurrentTryAppParams, + }} + > + <AppList /> + </ExploreContext.Provider> + </NuqsTestingAdapter>, + ) + + fireEvent.click(screen.getByTestId('try-app-create')) + + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + }) + }) + + it('should render try app panel with empty appId when currentApp is undefined', () => { + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + + render( + <NuqsTestingAdapter> + <ExploreContext.Provider + value={{ + controlUpdateInstalledApps: 0, + setControlUpdateInstalledApps: vi.fn(), + hasEditPermission: true, + installedApps: [], + setInstalledApps: vi.fn(), + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: true, + setShowTryAppPanel: vi.fn(), + }} + > + <AppList /> + </ExploreContext.Provider> + </NuqsTestingAdapter>, + ) + + expect(screen.getByTestId('try-app-panel')).toBeInTheDocument() + }) + }) + + describe('Banner', () => { + it('should render banner when enable_explore_banner is true', () => { + useGlobalPublicStore.setState({ + systemFeatures: { + ...useGlobalPublicStore.getState().systemFeatures, + enable_explore_banner: true, + }, + }) + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + + renderWithContext() + + expect(screen.getByTestId('explore-banner')).toBeInTheDocument() + }) + }) }) diff --git a/web/app/components/explore/try-app/__tests__/index.spec.tsx b/web/app/components/explore/try-app/__tests__/index.spec.tsx index 44a413bbad..e46155a217 100644 --- a/web/app/components/explore/try-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx @@ -4,6 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import TryApp from '../index' import { TypeEnum } from '../tab' +// Suppress expected React act() warnings from internal async state updates +vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() as object return { diff --git a/web/app/components/goto-anything/actions/__tests__/app.spec.ts b/web/app/components/goto-anything/actions/__tests__/app.spec.ts index 2a09b8be1d..922be7675b 100644 --- a/web/app/components/goto-anything/actions/__tests__/app.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/app.spec.ts @@ -13,10 +13,6 @@ vi.mock('../../../app/type-selector', () => ({ AppTypeIcon: () => null, })) -vi.mock('../../../base/app-icon', () => ({ - default: () => null, -})) - describe('appAction', () => { beforeEach(() => { vi.clearAllMocks() @@ -62,10 +58,13 @@ describe('appAction', () => { }) it('returns empty array on API failure', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { fetchAppList } = await import('@/service/apps') vi.mocked(fetchAppList).mockRejectedValue(new Error('network error')) const results = await appAction.search('@app fail', 'fail', 'en') expect(results).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith('App search failed:', expect.any(Error)) + warnSpy.mockRestore() }) }) diff --git a/web/app/components/goto-anything/actions/__tests__/index.spec.ts b/web/app/components/goto-anything/actions/__tests__/index.spec.ts index 8b92297a57..12bdb192f2 100644 --- a/web/app/components/goto-anything/actions/__tests__/index.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/index.spec.ts @@ -146,6 +146,7 @@ describe('searchAnything', () => { }) it('handles action search failure gracefully', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const action: ActionItem = { key: '@app', shortcut: '@app', @@ -156,6 +157,11 @@ describe('searchAnything', () => { const results = await searchAnything('en', '@app test', action) expect(results).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Search failed for @app'), + expect.any(Error), + ) + warnSpy.mockRestore() }) it('runs global search across all non-slash actions for plain queries', async () => { @@ -183,6 +189,7 @@ describe('searchAnything', () => { }) it('handles partial search failures in global search gracefully', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const dynamicActions: Record<string, ActionItem> = { app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockRejectedValue(new Error('fail')) }, knowledge: { @@ -200,6 +207,8 @@ describe('searchAnything', () => { expect(results).toHaveLength(1) expect(results[0].id).toBe('k1') + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() }) }) diff --git a/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts b/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts index cb39bea0e5..0d78e6cd41 100644 --- a/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts @@ -9,10 +9,6 @@ vi.mock('@/utils/classnames', () => ({ cn: (...args: string[]) => args.filter(Boolean).join(' '), })) -vi.mock('../../../base/icons/src/vender/solid/files', () => ({ - Folder: () => null, -})) - describe('knowledgeAction', () => { beforeEach(() => { vi.clearAllMocks() @@ -84,10 +80,13 @@ describe('knowledgeAction', () => { }) it('returns empty array on API failure', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { fetchDatasets } = await import('@/service/datasets') vi.mocked(fetchDatasets).mockRejectedValue(new Error('fail')) const results = await knowledgeAction.search('@knowledge', 'fail', 'en') expect(results).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith('Knowledge search failed:', expect.any(Error)) + warnSpy.mockRestore() }) }) diff --git a/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts b/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts index a5d8fe444c..dd40b1dc98 100644 --- a/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts @@ -55,18 +55,27 @@ describe('pluginAction', () => { }) it('returns empty array when response has unexpected structure', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { postMarketplace } = await import('@/service/base') vi.mocked(postMarketplace).mockResolvedValue({ data: {} }) const results = await pluginAction.search('@plugin', 'test', 'en') expect(results).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith( + 'Plugin search: Unexpected response structure', + expect.anything(), + ) + warnSpy.mockRestore() }) it('returns empty array on API failure', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { postMarketplace } = await import('@/service/base') vi.mocked(postMarketplace).mockRejectedValue(new Error('fail')) const results = await pluginAction.search('@plugin', 'fail', 'en') expect(results).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith('Plugin search failed:', expect.any(Error)) + warnSpy.mockRestore() }) }) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts index 1366c27245..88bd8b1045 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts +++ b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts @@ -12,9 +12,10 @@ import { forumCommand } from '../forum' vi.mock('../command-bus') +const mockT = vi.fn((key: string) => key) vi.mock('react-i18next', () => ({ getI18n: () => ({ - t: (key: string) => key, + t: (key: string) => mockT(key), language: 'en', }), })) @@ -62,11 +63,32 @@ describe('docsCommand', () => { }) }) + it('search uses fallback description when i18n returns empty', async () => { + mockT.mockImplementation((key: string) => + key.includes('docDesc') ? '' : key, + ) + + const results = await docsCommand.search('', 'en') + + expect(results[0].description).toBe('Open help documentation') + mockT.mockImplementation((key: string) => key) + }) + it('registers navigation.doc command', () => { docsCommand.register?.({} as Record<string, never>) expect(registerCommands).toHaveBeenCalledWith({ 'navigation.doc': expect.any(Function) }) }) + it('registered handler opens doc URL with correct locale', async () => { + docsCommand.register?.({} as Record<string, never>) + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + const handlers = vi.mocked(registerCommands).mock.calls[0][0] + await handlers['navigation.doc']() + + expect(openSpy).toHaveBeenCalledWith('https://docs.dify.ai/en', '_blank', 'noopener,noreferrer') + openSpy.mockRestore() + }) + it('unregisters navigation.doc command', () => { docsCommand.unregister?.() expect(unregisterCommands).toHaveBeenCalledWith(['navigation.doc']) @@ -154,11 +176,42 @@ describe('communityCommand', () => { }) }) + it('search uses fallback description when i18n returns empty', async () => { + mockT.mockImplementation((key: string) => + key.includes('communityDesc') ? '' : key, + ) + + const results = await communityCommand.search('', 'en') + + expect(results[0].description).toBe('Open Discord community') + mockT.mockImplementation((key: string) => key) + }) + it('registers navigation.community command', () => { communityCommand.register?.({} as Record<string, never>) expect(registerCommands).toHaveBeenCalledWith({ 'navigation.community': expect.any(Function) }) }) + it('registered handler opens URL from args', async () => { + communityCommand.register?.({} as Record<string, never>) + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + const handlers = vi.mocked(registerCommands).mock.calls[0][0] + await handlers['navigation.community']({ url: 'https://custom-url.com' }) + + expect(openSpy).toHaveBeenCalledWith('https://custom-url.com', '_blank', 'noopener,noreferrer') + openSpy.mockRestore() + }) + + it('registered handler falls back to default URL when no args', async () => { + communityCommand.register?.({} as Record<string, never>) + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + const handlers = vi.mocked(registerCommands).mock.calls[0][0] + await handlers['navigation.community']() + + expect(openSpy).toHaveBeenCalledWith('https://discord.gg/5AEfbxcd9k', '_blank', 'noopener,noreferrer') + openSpy.mockRestore() + }) + it('unregisters navigation.community command', () => { communityCommand.unregister?.() expect(unregisterCommands).toHaveBeenCalledWith(['navigation.community']) @@ -200,11 +253,42 @@ describe('forumCommand', () => { }) }) + it('search uses fallback description when i18n returns empty', async () => { + mockT.mockImplementation((key: string) => + key.includes('feedbackDesc') ? '' : key, + ) + + const results = await forumCommand.search('', 'en') + + expect(results[0].description).toBe('Open community feedback discussions') + mockT.mockImplementation((key: string) => key) + }) + it('registers navigation.forum command', () => { forumCommand.register?.({} as Record<string, never>) expect(registerCommands).toHaveBeenCalledWith({ 'navigation.forum': expect.any(Function) }) }) + it('registered handler opens URL from args', async () => { + forumCommand.register?.({} as Record<string, never>) + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + const handlers = vi.mocked(registerCommands).mock.calls[0][0] + await handlers['navigation.forum']({ url: 'https://custom-forum.com' }) + + expect(openSpy).toHaveBeenCalledWith('https://custom-forum.com', '_blank', 'noopener,noreferrer') + openSpy.mockRestore() + }) + + it('registered handler falls back to default URL when no args', async () => { + forumCommand.register?.({} as Record<string, never>) + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + const handlers = vi.mocked(registerCommands).mock.calls[0][0] + await handlers['navigation.forum']() + + expect(openSpy).toHaveBeenCalledWith('https://forum.dify.ai', '_blank', 'noopener,noreferrer') + openSpy.mockRestore() + }) + it('unregisters navigation.forum command', () => { forumCommand.unregister?.() expect(unregisterCommands).toHaveBeenCalledWith(['navigation.forum']) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts index 2488ffed28..2a13ffd1ea 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts +++ b/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts @@ -214,6 +214,7 @@ describe('SlashCommandRegistry', () => { }) it('returns empty when handler.search throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const handler = createHandler({ name: 'broken', search: vi.fn().mockRejectedValue(new Error('fail')), @@ -222,6 +223,11 @@ describe('SlashCommandRegistry', () => { const results = await registry.search('/broken') expect(results).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Command search failed'), + expect.any(Error), + ) + warnSpy.mockRestore() }) it('excludes unavailable commands from root listing', async () => { diff --git a/web/app/components/plugins/__tests__/hooks.spec.ts b/web/app/components/plugins/__tests__/hooks.spec.ts index a8a8c43102..b12121d626 100644 --- a/web/app/components/plugins/__tests__/hooks.spec.ts +++ b/web/app/components/plugins/__tests__/hooks.spec.ts @@ -7,142 +7,55 @@ describe('useTags', () => { vi.clearAllMocks() }) - describe('Rendering', () => { - it('should return tags array', () => { - const { result } = renderHook(() => useTags()) + it('should return non-empty tags array with name and label properties', () => { + const { result } = renderHook(() => useTags()) - expect(result.current.tags).toBeDefined() - expect(Array.isArray(result.current.tags)).toBe(true) - expect(result.current.tags.length).toBeGreaterThan(0) - }) - - it('should return tags with translated labels', () => { - const { result } = renderHook(() => useTags()) - - result.current.tags.forEach((tag) => { - expect(tag.label).toBe(`pluginTags.tags.${tag.name}`) - }) - }) - - it('should return tags with name and label properties', () => { - const { result } = renderHook(() => useTags()) - - result.current.tags.forEach((tag) => { - expect(tag).toHaveProperty('name') - expect(tag).toHaveProperty('label') - expect(typeof tag.name).toBe('string') - expect(typeof tag.label).toBe('string') - }) - }) - - it('should return tagsMap object', () => { - const { result } = renderHook(() => useTags()) - - expect(result.current.tagsMap).toBeDefined() - expect(typeof result.current.tagsMap).toBe('object') + expect(result.current.tags.length).toBeGreaterThan(0) + result.current.tags.forEach((tag) => { + expect(typeof tag.name).toBe('string') + expect(tag.label).toBe(`pluginTags.tags.${tag.name}`) }) }) - describe('tagsMap', () => { - it('should map tag name to tag object', () => { - const { result } = renderHook(() => useTags()) + it('should build a tagsMap that maps every tag name to its object', () => { + const { result } = renderHook(() => useTags()) - expect(result.current.tagsMap.agent).toBeDefined() - expect(result.current.tagsMap.agent.name).toBe('agent') - expect(result.current.tagsMap.agent.label).toBe('pluginTags.tags.agent') - }) - - it('should contain all tags from tags array', () => { - const { result } = renderHook(() => useTags()) - - result.current.tags.forEach((tag) => { - expect(result.current.tagsMap[tag.name]).toBeDefined() - expect(result.current.tagsMap[tag.name]).toEqual(tag) - }) + result.current.tags.forEach((tag) => { + expect(result.current.tagsMap[tag.name]).toEqual(tag) }) }) describe('getTagLabel', () => { - it('should return label for existing tag', () => { + it('should return translated label for existing tags', () => { const { result } = renderHook(() => useTags()) expect(result.current.getTagLabel('agent')).toBe('pluginTags.tags.agent') expect(result.current.getTagLabel('search')).toBe('pluginTags.tags.search') + expect(result.current.getTagLabel('rag')).toBe('pluginTags.tags.rag') }) - it('should return name for non-existing tag', () => { + it('should return the name itself for non-existing tags', () => { const { result } = renderHook(() => useTags()) - // Test non-existing tags - this covers the branch where !tagsMap[name] expect(result.current.getTagLabel('non-existing')).toBe('non-existing') expect(result.current.getTagLabel('custom-tag')).toBe('custom-tag') }) - it('should cover both branches of getTagLabel conditional', () => { + it('should handle edge cases: empty string and special characters', () => { const { result } = renderHook(() => useTags()) - const existingTagResult = result.current.getTagLabel('rag') - expect(existingTagResult).toBe('pluginTags.tags.rag') - - const nonExistingTagResult = result.current.getTagLabel('unknown-tag-xyz') - expect(nonExistingTagResult).toBe('unknown-tag-xyz') - }) - - it('should be a function', () => { - const { result } = renderHook(() => useTags()) - - expect(typeof result.current.getTagLabel).toBe('function') - }) - - it('should return correct labels for all predefined tags', () => { - const { result } = renderHook(() => useTags()) - - expect(result.current.getTagLabel('rag')).toBe('pluginTags.tags.rag') - expect(result.current.getTagLabel('image')).toBe('pluginTags.tags.image') - expect(result.current.getTagLabel('videos')).toBe('pluginTags.tags.videos') - expect(result.current.getTagLabel('weather')).toBe('pluginTags.tags.weather') - expect(result.current.getTagLabel('finance')).toBe('pluginTags.tags.finance') - expect(result.current.getTagLabel('design')).toBe('pluginTags.tags.design') - expect(result.current.getTagLabel('travel')).toBe('pluginTags.tags.travel') - expect(result.current.getTagLabel('social')).toBe('pluginTags.tags.social') - expect(result.current.getTagLabel('news')).toBe('pluginTags.tags.news') - expect(result.current.getTagLabel('medical')).toBe('pluginTags.tags.medical') - expect(result.current.getTagLabel('productivity')).toBe('pluginTags.tags.productivity') - expect(result.current.getTagLabel('education')).toBe('pluginTags.tags.education') - expect(result.current.getTagLabel('business')).toBe('pluginTags.tags.business') - expect(result.current.getTagLabel('entertainment')).toBe('pluginTags.tags.entertainment') - expect(result.current.getTagLabel('utilities')).toBe('pluginTags.tags.utilities') - expect(result.current.getTagLabel('other')).toBe('pluginTags.tags.other') - }) - - it('should handle empty string tag name', () => { - const { result } = renderHook(() => useTags()) - - // Empty string tag doesn't exist, so should return the empty string expect(result.current.getTagLabel('')).toBe('') - }) - - it('should handle special characters in tag name', () => { - const { result } = renderHook(() => useTags()) - expect(result.current.getTagLabel('tag-with-dashes')).toBe('tag-with-dashes') expect(result.current.getTagLabel('tag_with_underscores')).toBe('tag_with_underscores') }) }) - describe('Memoization', () => { - it('should return same structure on re-render', () => { - const { result, rerender } = renderHook(() => useTags()) + it('should return same structure on re-render', () => { + const { result, rerender } = renderHook(() => useTags()) - const firstTagsLength = result.current.tags.length - const firstTagNames = result.current.tags.map(t => t.name) - - rerender() - - // Structure should remain consistent - expect(result.current.tags.length).toBe(firstTagsLength) - expect(result.current.tags.map(t => t.name)).toEqual(firstTagNames) - }) + const firstTagNames = result.current.tags.map(t => t.name) + rerender() + expect(result.current.tags.map(t => t.name)).toEqual(firstTagNames) }) }) @@ -151,93 +64,46 @@ describe('useCategories', () => { vi.clearAllMocks() }) - describe('Rendering', () => { - it('should return categories array', () => { - const { result } = renderHook(() => useCategories()) + it('should return non-empty categories array with name and label properties', () => { + const { result } = renderHook(() => useCategories()) - expect(result.current.categories).toBeDefined() - expect(Array.isArray(result.current.categories)).toBe(true) - expect(result.current.categories.length).toBeGreaterThan(0) - }) - - it('should return categories with name and label properties', () => { - const { result } = renderHook(() => useCategories()) - - result.current.categories.forEach((category) => { - expect(category).toHaveProperty('name') - expect(category).toHaveProperty('label') - expect(typeof category.name).toBe('string') - expect(typeof category.label).toBe('string') - }) - }) - - it('should return categoriesMap object', () => { - const { result } = renderHook(() => useCategories()) - - expect(result.current.categoriesMap).toBeDefined() - expect(typeof result.current.categoriesMap).toBe('object') + expect(result.current.categories.length).toBeGreaterThan(0) + result.current.categories.forEach((category) => { + expect(typeof category.name).toBe('string') + expect(typeof category.label).toBe('string') }) }) - describe('categoriesMap', () => { - it('should map category name to category object', () => { - const { result } = renderHook(() => useCategories()) + it('should build a categoriesMap that maps every category name to its object', () => { + const { result } = renderHook(() => useCategories()) - expect(result.current.categoriesMap.tool).toBeDefined() - expect(result.current.categoriesMap.tool.name).toBe('tool') - }) - - it('should contain all categories from categories array', () => { - const { result } = renderHook(() => useCategories()) - - result.current.categories.forEach((category) => { - expect(result.current.categoriesMap[category.name]).toBeDefined() - expect(result.current.categoriesMap[category.name]).toEqual(category) - }) + result.current.categories.forEach((category) => { + expect(result.current.categoriesMap[category.name]).toEqual(category) }) }) describe('isSingle parameter', () => { - it('should use plural labels when isSingle is false', () => { - const { result } = renderHook(() => useCategories(false)) - - expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools') - }) - - it('should use plural labels when isSingle is undefined', () => { + it('should use plural labels by default', () => { const { result } = renderHook(() => useCategories()) expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools') + expect(result.current.categoriesMap['agent-strategy'].label).toBe('plugin.category.agents') }) it('should use singular labels when isSingle is true', () => { const { result } = renderHook(() => useCategories(true)) expect(result.current.categoriesMap.tool.label).toBe('plugin.categorySingle.tool') - }) - - it('should handle agent category specially', () => { - const { result: resultPlural } = renderHook(() => useCategories(false)) - const { result: resultSingle } = renderHook(() => useCategories(true)) - - expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('plugin.category.agents') - expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('plugin.categorySingle.agent') + expect(result.current.categoriesMap['agent-strategy'].label).toBe('plugin.categorySingle.agent') }) }) - describe('Memoization', () => { - it('should return same structure on re-render', () => { - const { result, rerender } = renderHook(() => useCategories()) + it('should return same structure on re-render', () => { + const { result, rerender } = renderHook(() => useCategories()) - const firstCategoriesLength = result.current.categories.length - const firstCategoryNames = result.current.categories.map(c => c.name) - - rerender() - - // Structure should remain consistent - expect(result.current.categories.length).toBe(firstCategoriesLength) - expect(result.current.categories.map(c => c.name)).toEqual(firstCategoryNames) - }) + const firstCategoryNames = result.current.categories.map(c => c.name) + rerender() + expect(result.current.categories.map(c => c.name)).toEqual(firstCategoryNames) }) }) @@ -246,103 +112,26 @@ describe('usePluginPageTabs', () => { vi.clearAllMocks() }) - describe('Rendering', () => { - it('should return tabs array', () => { - const { result } = renderHook(() => usePluginPageTabs()) + it('should return two tabs: plugins first, marketplace second', () => { + const { result } = renderHook(() => usePluginPageTabs()) - expect(result.current).toBeDefined() - expect(Array.isArray(result.current)).toBe(true) - }) - - it('should return two tabs', () => { - const { result } = renderHook(() => usePluginPageTabs()) - - expect(result.current.length).toBe(2) - }) - - it('should return tabs with value and text properties', () => { - const { result } = renderHook(() => usePluginPageTabs()) - - result.current.forEach((tab) => { - expect(tab).toHaveProperty('value') - expect(tab).toHaveProperty('text') - expect(typeof tab.value).toBe('string') - expect(typeof tab.text).toBe('string') - }) - }) - - it('should return tabs with translated texts', () => { - const { result } = renderHook(() => usePluginPageTabs()) - - expect(result.current[0].text).toBe('common.menus.plugins') - expect(result.current[1].text).toBe('common.menus.exploreMarketplace') - }) + expect(result.current).toHaveLength(2) + expect(result.current[0]).toEqual({ value: 'plugins', text: 'common.menus.plugins' }) + expect(result.current[1]).toEqual({ value: 'discover', text: 'common.menus.exploreMarketplace' }) }) - describe('Tab Values', () => { - it('should have plugins tab with correct value', () => { - const { result } = renderHook(() => usePluginPageTabs()) + it('should have consistent structure across re-renders', () => { + const { result, rerender } = renderHook(() => usePluginPageTabs()) - const pluginsTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.plugins) - expect(pluginsTab).toBeDefined() - expect(pluginsTab?.value).toBe('plugins') - expect(pluginsTab?.text).toBe('common.menus.plugins') - }) - - it('should have marketplace tab with correct value', () => { - const { result } = renderHook(() => usePluginPageTabs()) - - const marketplaceTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.marketplace) - expect(marketplaceTab).toBeDefined() - expect(marketplaceTab?.value).toBe('discover') - expect(marketplaceTab?.text).toBe('common.menus.exploreMarketplace') - }) - }) - - describe('Tab Order', () => { - it('should return plugins tab as first tab', () => { - const { result } = renderHook(() => usePluginPageTabs()) - - expect(result.current[0].value).toBe('plugins') - expect(result.current[0].text).toBe('common.menus.plugins') - }) - - it('should return marketplace tab as second tab', () => { - const { result } = renderHook(() => usePluginPageTabs()) - - expect(result.current[1].value).toBe('discover') - expect(result.current[1].text).toBe('common.menus.exploreMarketplace') - }) - }) - - describe('Tab Structure', () => { - it('should have consistent structure across re-renders', () => { - const { result, rerender } = renderHook(() => usePluginPageTabs()) - - const firstTabs = [...result.current] - rerender() - - expect(result.current).toEqual(firstTabs) - }) - - it('should return new array reference on each call', () => { - const { result, rerender } = renderHook(() => usePluginPageTabs()) - - const firstTabs = result.current - rerender() - - // Each call creates a new array (not memoized) - expect(result.current).not.toBe(firstTabs) - }) + const firstTabs = [...result.current] + rerender() + expect(result.current).toEqual(firstTabs) }) }) describe('PLUGIN_PAGE_TABS_MAP', () => { - it('should have plugins key with correct value', () => { + it('should have correct key-value mappings', () => { expect(PLUGIN_PAGE_TABS_MAP.plugins).toBe('plugins') - }) - - it('should have marketplace key with correct value', () => { expect(PLUGIN_PAGE_TABS_MAP.marketplace).toBe('discover') }) }) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-fold-anim-into.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-fold-anim-into.spec.ts new file mode 100644 index 0000000000..a128c1f16f --- /dev/null +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-fold-anim-into.spec.ts @@ -0,0 +1,171 @@ +import type { Mock } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('useFoldAnimInto', () => { + let mockOnClose: Mock<() => void> + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockOnClose = vi.fn<() => void>() + }) + + afterEach(() => { + vi.useRealTimers() + document.querySelectorAll('.install-modal, #plugin-task-trigger, .plugins-nav-button') + .forEach(el => el.remove()) + }) + + it('should return modalClassName and functions', async () => { + const useFoldAnimInto = (await import('../use-fold-anim-into')).default + const { result } = renderHook(() => useFoldAnimInto(mockOnClose)) + + expect(result.current.modalClassName).toBe('install-modal') + expect(typeof result.current.foldIntoAnim).toBe('function') + expect(typeof result.current.clearCountDown).toBe('function') + expect(typeof result.current.countDownFoldIntoAnim).toBe('function') + }) + + describe('foldIntoAnim', () => { + it('should call onClose immediately when modal element is not found', async () => { + const useFoldAnimInto = (await import('../use-fold-anim-into')).default + const { result } = renderHook(() => useFoldAnimInto(mockOnClose)) + + await act(async () => { + await result.current.foldIntoAnim() + }) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when modal exists but trigger element is not found', async () => { + const useFoldAnimInto = (await import('../use-fold-anim-into')).default + const { result } = renderHook(() => useFoldAnimInto(mockOnClose)) + + const modal = document.createElement('div') + modal.className = 'install-modal' + document.body.appendChild(modal) + + await act(async () => { + await result.current.foldIntoAnim() + }) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should animate and call onClose when both elements exist', async () => { + const useFoldAnimInto = (await import('../use-fold-anim-into')).default + const { result } = renderHook(() => useFoldAnimInto(mockOnClose)) + + const modal = document.createElement('div') + modal.className = 'install-modal' + Object.defineProperty(modal, 'getBoundingClientRect', { + value: () => ({ left: 100, top: 100, width: 400, height: 300 }), + }) + document.body.appendChild(modal) + + // Set up trigger element with id + const trigger = document.createElement('div') + trigger.id = 'plugin-task-trigger' + Object.defineProperty(trigger, 'getBoundingClientRect', { + value: () => ({ left: 50, top: 50, width: 40, height: 40 }), + }) + document.body.appendChild(trigger) + + await act(async () => { + await result.current.foldIntoAnim() + }) + + // Should apply animation styles + expect(modal.style.transition).toContain('750ms') + expect(modal.style.transform).toContain('translate') + expect(modal.style.transform).toContain('scale') + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should use plugins-nav-button as fallback trigger element', async () => { + const useFoldAnimInto = (await import('../use-fold-anim-into')).default + const { result } = renderHook(() => useFoldAnimInto(mockOnClose)) + + const modal = document.createElement('div') + modal.className = 'install-modal' + Object.defineProperty(modal, 'getBoundingClientRect', { + value: () => ({ left: 200, top: 200, width: 500, height: 400 }), + }) + document.body.appendChild(modal) + + // No #plugin-task-trigger, use .plugins-nav-button fallback + const navButton = document.createElement('div') + navButton.className = 'plugins-nav-button' + Object.defineProperty(navButton, 'getBoundingClientRect', { + value: () => ({ left: 10, top: 10, width: 30, height: 30 }), + }) + document.body.appendChild(navButton) + + await act(async () => { + await result.current.foldIntoAnim() + }) + + expect(modal.style.transform).toContain('translate') + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('clearCountDown', () => { + it('should clear the countdown timer', async () => { + const useFoldAnimInto = (await import('../use-fold-anim-into')).default + const { result } = renderHook(() => useFoldAnimInto(mockOnClose)) + + // Start countdown then clear it + await act(async () => { + result.current.countDownFoldIntoAnim() + }) + + result.current.clearCountDown() + + // Advance past the countdown time — onClose should NOT be called + await act(async () => { + vi.advanceTimersByTime(20000) + }) + + // onClose might still be called because foldIntoAnim's inner logic + // could fire, but the setTimeout itself should be cleared + }) + }) + + describe('countDownFoldIntoAnim', () => { + it('should trigger foldIntoAnim after 15 seconds', async () => { + const useFoldAnimInto = (await import('../use-fold-anim-into')).default + const { result } = renderHook(() => useFoldAnimInto(mockOnClose)) + + await act(async () => { + result.current.countDownFoldIntoAnim() + }) + + // Advance by 15 seconds + await act(async () => { + vi.advanceTimersByTime(15000) + }) + + // foldIntoAnim would be called, but no modal in DOM so onClose is called directly + expect(mockOnClose).toHaveBeenCalled() + }) + + it('should not trigger before 15 seconds', async () => { + const useFoldAnimInto = (await import('../use-fold-anim-into')).default + const { result } = renderHook(() => useFoldAnimInto(mockOnClose)) + + await act(async () => { + result.current.countDownFoldIntoAnim() + }) + + // Advance only 10 seconds + await act(async () => { + vi.advanceTimersByTime(10000) + }) + + expect(mockOnClose).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/__tests__/ready-to-install.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/__tests__/ready-to-install.spec.tsx new file mode 100644 index 0000000000..1d2f4de620 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/__tests__/ready-to-install.spec.tsx @@ -0,0 +1,268 @@ +import type { Dependency, InstallStatus, Plugin } from '../../../types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { InstallStep } from '../../../types' +import ReadyToInstall from '../ready-to-install' + +// Track the onInstalled callback from the Install component +let capturedOnInstalled: ((plugins: Plugin[], installStatus: InstallStatus[]) => void) | null = null + +vi.mock('../steps/install', () => ({ + default: ({ + allPlugins, + onCancel, + onStartToInstall, + onInstalled, + isFromMarketPlace, + }: { + allPlugins: Dependency[] + onCancel: () => void + onStartToInstall: () => void + onInstalled: (plugins: Plugin[], installStatus: InstallStatus[]) => void + isFromMarketPlace?: boolean + }) => { + capturedOnInstalled = onInstalled + return ( + <div data-testid="install-step"> + <span data-testid="install-plugins-count">{allPlugins?.length}</span> + <span data-testid="install-from-marketplace">{String(!!isFromMarketPlace)}</span> + <button data-testid="install-cancel-btn" onClick={onCancel}>Cancel</button> + <button data-testid="install-start-btn" onClick={onStartToInstall}>Start</button> + <button + data-testid="install-complete-btn" + onClick={() => onInstalled( + [{ plugin_id: 'p1', name: 'Plugin 1' } as Plugin], + [{ success: true, isFromMarketPlace: true }], + )} + > + Complete + </button> + </div> + ) + }, +})) + +vi.mock('../steps/installed', () => ({ + default: ({ + list, + installStatus, + onCancel, + }: { + list: Plugin[] + installStatus: InstallStatus[] + onCancel: () => void + }) => ( + <div data-testid="installed-step"> + <span data-testid="installed-count">{list.length}</span> + <span data-testid="installed-status-count">{installStatus.length}</span> + <button data-testid="installed-close-btn" onClick={onCancel}>Close</button> + </div> + ), +})) + +const createMockDependencies = (): Dependency[] => [ + { + type: 'marketplace', + value: { + marketplace_plugin_unique_identifier: 'plugin-1-uid', + }, + } as Dependency, + { + type: 'github', + value: { + repo: 'test/plugin2', + version: 'v1.0.0', + package: 'plugin2.zip', + }, + } as Dependency, +] + +describe('ReadyToInstall', () => { + const mockOnStepChange = vi.fn() + const mockOnStartToInstall = vi.fn() + const mockSetIsInstalling = vi.fn() + const mockOnClose = vi.fn() + + const defaultProps = { + step: InstallStep.readyToInstall, + onStepChange: mockOnStepChange, + onStartToInstall: mockOnStartToInstall, + setIsInstalling: mockSetIsInstalling, + allPlugins: createMockDependencies(), + onClose: mockOnClose, + } + + beforeEach(() => { + vi.clearAllMocks() + capturedOnInstalled = null + }) + + describe('readyToInstall step', () => { + it('should render Install component when step is readyToInstall', () => { + render(<ReadyToInstall {...defaultProps} />) + + expect(screen.getByTestId('install-step')).toBeInTheDocument() + expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument() + }) + + it('should pass allPlugins count to Install component', () => { + render(<ReadyToInstall {...defaultProps} />) + + expect(screen.getByTestId('install-plugins-count')).toHaveTextContent('2') + }) + + it('should pass isFromMarketPlace to Install component', () => { + render(<ReadyToInstall {...defaultProps} isFromMarketPlace />) + + expect(screen.getByTestId('install-from-marketplace')).toHaveTextContent('true') + }) + + it('should pass onClose as onCancel to Install', () => { + render(<ReadyToInstall {...defaultProps} />) + + fireEvent.click(screen.getByTestId('install-cancel-btn')) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should pass onStartToInstall to Install', () => { + render(<ReadyToInstall {...defaultProps} />) + + fireEvent.click(screen.getByTestId('install-start-btn')) + + expect(mockOnStartToInstall).toHaveBeenCalledTimes(1) + }) + }) + + describe('handleInstalled callback', () => { + it('should transition to installed step when Install completes', () => { + render(<ReadyToInstall {...defaultProps} />) + + // Trigger the onInstalled callback via the mock button + fireEvent.click(screen.getByTestId('install-complete-btn')) + + // Should update step to installed + expect(mockOnStepChange).toHaveBeenCalledWith(InstallStep.installed) + // Should set isInstalling to false + expect(mockSetIsInstalling).toHaveBeenCalledWith(false) + }) + + it('should store installed plugins and status for the Installed step', () => { + const { rerender } = render(<ReadyToInstall {...defaultProps} />) + + // Trigger install completion + fireEvent.click(screen.getByTestId('install-complete-btn')) + + // Re-render with step=installed to show Installed component + rerender( + <ReadyToInstall + {...defaultProps} + step={InstallStep.installed} + />, + ) + + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('installed-count')).toHaveTextContent('1') + expect(screen.getByTestId('installed-status-count')).toHaveTextContent('1') + }) + + it('should pass custom plugins and status via capturedOnInstalled', () => { + const { rerender } = render(<ReadyToInstall {...defaultProps} />) + + // Use the captured callback directly with custom data + expect(capturedOnInstalled).toBeTruthy() + act(() => { + capturedOnInstalled!( + [ + { plugin_id: 'p1', name: 'P1' } as Plugin, + { plugin_id: 'p2', name: 'P2' } as Plugin, + ], + [ + { success: true, isFromMarketPlace: true }, + { success: false, isFromMarketPlace: false }, + ], + ) + }) + + expect(mockOnStepChange).toHaveBeenCalledWith(InstallStep.installed) + expect(mockSetIsInstalling).toHaveBeenCalledWith(false) + + // Re-render at installed step + rerender( + <ReadyToInstall + {...defaultProps} + step={InstallStep.installed} + />, + ) + + expect(screen.getByTestId('installed-count')).toHaveTextContent('2') + expect(screen.getByTestId('installed-status-count')).toHaveTextContent('2') + }) + }) + + describe('installed step', () => { + it('should render Installed component when step is installed', () => { + render( + <ReadyToInstall + {...defaultProps} + step={InstallStep.installed} + />, + ) + + expect(screen.queryByTestId('install-step')).not.toBeInTheDocument() + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + + it('should pass onClose to Installed component', () => { + render( + <ReadyToInstall + {...defaultProps} + step={InstallStep.installed} + />, + ) + + fireEvent.click(screen.getByTestId('installed-close-btn')) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should render empty installed list initially', () => { + render( + <ReadyToInstall + {...defaultProps} + step={InstallStep.installed} + />, + ) + + expect(screen.getByTestId('installed-count')).toHaveTextContent('0') + expect(screen.getByTestId('installed-status-count')).toHaveTextContent('0') + }) + }) + + describe('edge cases', () => { + it('should render nothing when step is neither readyToInstall nor installed', () => { + const { container } = render( + <ReadyToInstall + {...defaultProps} + step={InstallStep.uploading} + />, + ) + + expect(screen.queryByTestId('install-step')).not.toBeInTheDocument() + expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument() + // Only the empty fragment wrapper + expect(container.innerHTML).toBe('') + }) + + it('should handle empty allPlugins array', () => { + render( + <ReadyToInstall + {...defaultProps} + allPlugins={[]} + />, + ) + + expect(screen.getByTestId('install-plugins-count')).toHaveTextContent('0') + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx b/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx new file mode 100644 index 0000000000..40fc47a9d2 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx @@ -0,0 +1,246 @@ +import type { UrlUpdateEvent } from 'nuqs/adapters/testing' +import type { ReactNode } from 'react' +import { act, renderHook } from '@testing-library/react' +import { Provider as JotaiProvider } from 'jotai' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_SORT } from '../constants' + +const createWrapper = (searchParams = '') => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() + const wrapper = ({ children }: { children: ReactNode }) => ( + <JotaiProvider> + <NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}> + {children} + </NuqsTestingAdapter> + </JotaiProvider> + ) + return { wrapper, onUrlUpdate } +} + +describe('Marketplace sort atoms', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return default sort value from useMarketplaceSort', async () => { + const { useMarketplaceSort } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceSort(), { wrapper }) + + expect(result.current[0]).toEqual(DEFAULT_SORT) + expect(typeof result.current[1]).toBe('function') + }) + + it('should return default sort value from useMarketplaceSortValue', async () => { + const { useMarketplaceSortValue } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceSortValue(), { wrapper }) + + expect(result.current).toEqual(DEFAULT_SORT) + }) + + it('should return setter from useSetMarketplaceSort', async () => { + const { useSetMarketplaceSort } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useSetMarketplaceSort(), { wrapper }) + + expect(typeof result.current).toBe('function') + }) + + it('should update sort value via useMarketplaceSort setter', async () => { + const { useMarketplaceSort } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceSort(), { wrapper }) + + act(() => { + result.current[1]({ sortBy: 'created_at', sortOrder: 'ASC' }) + }) + + expect(result.current[0]).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' }) + }) +}) + +describe('useSearchPluginText', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return empty string as default', async () => { + const { useSearchPluginText } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useSearchPluginText(), { wrapper }) + + expect(result.current[0]).toBe('') + expect(typeof result.current[1]).toBe('function') + }) + + it('should parse q from search params', async () => { + const { useSearchPluginText } = await import('../atoms') + const { wrapper } = createWrapper('?q=hello') + const { result } = renderHook(() => useSearchPluginText(), { wrapper }) + + expect(result.current[0]).toBe('hello') + }) + + it('should expose a setter function for search text', async () => { + const { useSearchPluginText } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useSearchPluginText(), { wrapper }) + + expect(typeof result.current[1]).toBe('function') + + // Calling the setter should not throw + await act(async () => { + result.current[1]('search term') + }) + }) +}) + +describe('useActivePluginType', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return "all" as default category', async () => { + const { useActivePluginType } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useActivePluginType(), { wrapper }) + + expect(result.current[0]).toBe('all') + }) + + it('should parse category from search params', async () => { + const { useActivePluginType } = await import('../atoms') + const { wrapper } = createWrapper('?category=tool') + const { result } = renderHook(() => useActivePluginType(), { wrapper }) + + expect(result.current[0]).toBe('tool') + }) +}) + +describe('useFilterPluginTags', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return empty array as default', async () => { + const { useFilterPluginTags } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useFilterPluginTags(), { wrapper }) + + expect(result.current[0]).toEqual([]) + }) + + it('should parse tags from search params', async () => { + const { useFilterPluginTags } = await import('../atoms') + const { wrapper } = createWrapper('?tags=search') + const { result } = renderHook(() => useFilterPluginTags(), { wrapper }) + + expect(result.current[0]).toEqual(['search']) + }) +}) + +describe('useMarketplaceSearchMode', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return false when no search text, no tags, and category has collections (all)', async () => { + const { useMarketplaceSearchMode } = await import('../atoms') + const { wrapper } = createWrapper('?category=all') + const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper }) + + // "all" is in PLUGIN_CATEGORY_WITH_COLLECTIONS, so search mode should be false + expect(result.current).toBe(false) + }) + + it('should return true when search text is present', async () => { + const { useMarketplaceSearchMode } = await import('../atoms') + const { wrapper } = createWrapper('?q=test&category=all') + const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper }) + + expect(result.current).toBe(true) + }) + + it('should return true when tags are present', async () => { + const { useMarketplaceSearchMode } = await import('../atoms') + const { wrapper } = createWrapper('?tags=search&category=all') + const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper }) + + expect(result.current).toBe(true) + }) + + it('should return true when category does not have collections (e.g. model)', async () => { + const { useMarketplaceSearchMode } = await import('../atoms') + const { wrapper } = createWrapper('?category=model') + const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper }) + + // "model" is NOT in PLUGIN_CATEGORY_WITH_COLLECTIONS, so search mode = true + expect(result.current).toBe(true) + }) + + it('should return false when category has collections (tool) and no search/tags', async () => { + const { useMarketplaceSearchMode } = await import('../atoms') + const { wrapper } = createWrapper('?category=tool') + const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper }) + + expect(result.current).toBe(false) + }) +}) + +describe('useMarketplaceMoreClick', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return a callback function', async () => { + const { useMarketplaceMoreClick } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper }) + + expect(typeof result.current).toBe('function') + }) + + it('should do nothing when called with no params', async () => { + const { useMarketplaceMoreClick } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper }) + + // Should not throw when called with undefined + act(() => { + result.current(undefined) + }) + }) + + it('should update search state when called with search params', async () => { + const { useMarketplaceMoreClick, useMarketplaceSortValue } = await import('../atoms') + const { wrapper } = createWrapper() + + const { result } = renderHook(() => ({ + handleMoreClick: useMarketplaceMoreClick(), + sort: useMarketplaceSortValue(), + }), { wrapper }) + + act(() => { + result.current.handleMoreClick({ + query: 'collection search', + sort_by: 'created_at', + sort_order: 'ASC', + }) + }) + + // Sort should be updated via the jotai atom + expect(result.current.sort).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' }) + }) + + it('should use defaults when search params fields are missing', async () => { + const { useMarketplaceMoreClick } = await import('../atoms') + const { wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper }) + + act(() => { + result.current({}) + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/hooks-integration.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hooks-integration.spec.tsx new file mode 100644 index 0000000000..ac583d66c5 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/hooks-integration.spec.tsx @@ -0,0 +1,369 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Integration tests for hooks.ts using real @tanstack/react-query + * instead of mocking it, to get proper V8 coverage of queryFn closures. + */ + +let mockPostMarketplaceShouldFail = false +const mockPostMarketplaceResponse = { + data: { + plugins: [ + { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, + ], + total: 1, + }, +} + +vi.mock('@/service/base', () => ({ + postMarketplace: vi.fn(async () => { + if (mockPostMarketplaceShouldFail) + throw new Error('Mock API error') + return 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}`, +})) + +const mockCollections = vi.fn() +const mockCollectionPlugins = vi.fn() + +vi.mock('@/service/client', () => ({ + marketplaceClient: { + collections: (...args: unknown[]) => mockCollections(...args), + collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args), + }, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + }, + }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ) + return { Wrapper, queryClient } +} + +describe('useMarketplaceCollectionsAndPlugins (integration)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCollections.mockResolvedValue({ + data: { + collections: [ + { name: 'col-1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + ], + }, + }) + mockCollectionPlugins.mockResolvedValue({ + data: { + plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }], + }, + }) + }) + + it('should fetch collections with real QueryClient when query is triggered', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper }) + + // Trigger query + result.current.queryMarketplaceCollectionsAndPlugins({ condition: 'category=tool' }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.marketplaceCollections).toBeDefined() + expect(result.current.marketplaceCollectionPluginsMap).toBeDefined() + }) + + it('should handle query with empty params (truthy)', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper }) + + result.current.queryMarketplaceCollectionsAndPlugins({}) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + }) + + it('should handle query without arguments (falsy branch)', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper }) + + // Call without arguments → query is undefined → falsy branch + result.current.queryMarketplaceCollectionsAndPlugins() + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + }) +}) + +describe('useMarketplacePluginsByCollectionId (integration)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCollectionPlugins.mockResolvedValue({ + data: { + plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }], + }, + }) + }) + + it('should return empty when collectionId is undefined', async () => { + const { useMarketplacePluginsByCollectionId } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePluginsByCollectionId(undefined), + { wrapper: Wrapper }, + ) + + expect(result.current.plugins).toEqual([]) + }) + + it('should fetch plugins when collectionId is provided', async () => { + const { useMarketplacePluginsByCollectionId } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePluginsByCollectionId('collection-1'), + { wrapper: Wrapper }, + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.plugins.length).toBeGreaterThan(0) + }) +}) + +describe('useMarketplacePlugins (integration)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPostMarketplaceShouldFail = false + }) + + it('should return initial state without query', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + + expect(result.current.plugins).toBeUndefined() + expect(result.current.total).toBeUndefined() + expect(result.current.page).toBe(0) + expect(result.current.isLoading).toBe(false) + }) + + it('should show isLoading during initial fetch', async () => { + // Delay the response so we can observe the loading state + const { postMarketplace } = await import('@/service/base') + vi.mocked(postMarketplace).mockImplementationOnce(() => new Promise((resolve) => { + setTimeout(() => resolve({ + data: { plugins: [], total: 0 }, + }), 200) + })) + + const { useMarketplacePlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + + result.current.queryPlugins({ query: 'loading-test' }) + + // The isLoading should be true while fetching with no data + // (isPending || (isFetching && !data)) + await waitFor(() => { + expect(result.current.isLoading).toBe(true) + }) + + // Eventually completes + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + }) + + it('should fetch plugins when queryPlugins is called', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + + result.current.queryPlugins({ + query: 'test', + category: 'tool', + sort_by: 'install_count', + sort_order: 'DESC', + page_size: 40, + }) + + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) + + expect(result.current.plugins!.length).toBeGreaterThan(0) + expect(result.current.total).toBe(1) + expect(result.current.page).toBe(1) + }) + + it('should handle bundle type query', async () => { + mockPostMarketplaceShouldFail = false + const bundleResponse = { + data: { + plugins: [], + bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }], + total: 1, + }, + } + const { postMarketplace } = await import('@/service/base') + vi.mocked(postMarketplace).mockResolvedValueOnce(bundleResponse) + + const { useMarketplacePlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + + result.current.queryPlugins({ + query: 'test', + type: 'bundle', + page_size: 40, + }) + + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) + }) + + it('should handle API error gracefully', async () => { + mockPostMarketplaceShouldFail = true + + const { useMarketplacePlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + + result.current.queryPlugins({ + query: 'failing', + }) + + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) + + expect(result.current.plugins).toEqual([]) + expect(result.current.total).toBe(0) + }) + + it('should reset plugins state', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + + result.current.queryPlugins({ query: 'test' }) + + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) + + result.current.resetPlugins() + + await waitFor(() => { + expect(result.current.plugins).toBeUndefined() + }) + }) + + it('should use default page_size of 40 when not provided', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + + result.current.queryPlugins({ + query: 'test', + category: 'all', + }) + + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) + }) + + it('should handle queryPluginsWithDebounced', async () => { + const { useMarketplacePlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + + result.current.queryPluginsWithDebounced({ + query: 'debounced', + }) + + // Real useDebounceFn has 500ms wait, so increase timeout + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }, { timeout: 3000 }) + }) + + it('should handle response with bundles field (bundles || plugins fallback)', async () => { + const { postMarketplace } = await import('@/service/base') + vi.mocked(postMarketplace).mockResolvedValueOnce({ + data: { + bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }], + plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }], + total: 2, + }, + }) + + const { useMarketplacePlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + + result.current.queryPlugins({ + query: 'test-bundles-fallback', + type: 'bundle', + }) + + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) + + // Should use bundles (truthy first in || chain) + expect(result.current.plugins!.length).toBeGreaterThan(0) + }) + + it('should handle response with no bundles and no plugins (empty fallback)', async () => { + const { postMarketplace } = await import('@/service/base') + vi.mocked(postMarketplace).mockResolvedValueOnce({ + data: { + total: 0, + }, + }) + + const { useMarketplacePlugins } = await import('../hooks') + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + + result.current.queryPlugins({ + query: 'test-empty-fallback', + }) + + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) + + expect(result.current.plugins).toEqual([]) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx index ddbef3542a..2555a41f6b 100644 --- a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx @@ -1,10 +1,8 @@ -import { render, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, render, renderHook, waitFor } 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, @@ -26,62 +24,19 @@ vi.mock('@/service/use-plugins', () => ({ }), })) -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(), - }), -})) +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ) + return { Wrapper, queryClient } +} let mockPostMarketplaceShouldFail = false const mockPostMarketplaceResponse = { @@ -150,59 +105,26 @@ vi.mock('@/service/client', () => ({ }, })) -// ================================ -// useMarketplaceCollectionsAndPlugins Tests -// ================================ describe('useMarketplaceCollectionsAndPlugins', () => { beforeEach(() => { vi.clearAllMocks() }) - it('should return initial state correctly', async () => { + it('should return initial state with all required properties', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper }) 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() @@ -210,7 +132,11 @@ describe('useMarketplacePluginsByCollectionId', () => { it('should return initial state when collectionId is undefined', async () => { const { useMarketplacePluginsByCollectionId } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined)) + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePluginsByCollectionId(undefined), + { wrapper: Wrapper }, + ) expect(result.current.plugins).toEqual([]) expect(result.current.isLoading).toBe(false) expect(result.current.isSuccess).toBe(false) @@ -218,39 +144,54 @@ describe('useMarketplacePluginsByCollectionId', () => { it('should return isLoading false when collectionId is provided and query completes', async () => { const { useMarketplacePluginsByCollectionId } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection')) + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePluginsByCollectionId('test-collection'), + { wrapper: Wrapper }, + ) + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) expect(result.current.isLoading).toBe(false) }) it('should accept query parameter', async () => { const { useMarketplacePluginsByCollectionId } = await import('../hooks') - const { result } = renderHook(() => - useMarketplacePluginsByCollectionId('test-collection', { + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePluginsByCollectionId('test-collection', { category: 'tool', type: 'plugin', - })) - expect(result.current.plugins).toBeDefined() + }), + { wrapper: Wrapper }, + ) + await waitFor(() => { + 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() + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePluginsByCollectionId('collection-1'), + { wrapper: Wrapper }, + ) + await waitFor(() => { + 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()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) expect(result.current.plugins).toBeUndefined() expect(result.current.total).toBeUndefined() expect(result.current.isLoading).toBe(false) @@ -259,39 +200,21 @@ describe('useMarketplacePlugins', () => { expect(result.current.page).toBe(0) }) - it('should provide queryPlugins function', async () => { + it('should expose all required functions', async () => { const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) 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()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) expect(() => { result.current.queryPlugins({ query: 'test', @@ -305,7 +228,8 @@ describe('useMarketplacePlugins', () => { it('should handle queryPlugins with bundle type', async () => { const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) expect(() => { result.current.queryPlugins({ query: 'test', @@ -317,7 +241,8 @@ describe('useMarketplacePlugins', () => { it('should handle resetPlugins call', async () => { const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) expect(() => { result.current.resetPlugins() }).not.toThrow() @@ -325,18 +250,28 @@ describe('useMarketplacePlugins', () => { it('should handle queryPluginsWithDebounced call', async () => { const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) + vi.useFakeTimers() expect(() => { result.current.queryPluginsWithDebounced({ query: 'debounced search', category: 'all', }) }).not.toThrow() + act(() => { + vi.advanceTimersByTime(500) + }) + vi.useRealTimers() + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) }) it('should handle cancelQueryPluginsWithDebounced call', async () => { const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) expect(() => { result.current.cancelQueryPluginsWithDebounced() }).not.toThrow() @@ -344,13 +279,15 @@ describe('useMarketplacePlugins', () => { it('should return correct page number', async () => { const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) expect(result.current.page).toBe(0) }) it('should handle queryPlugins with tags', async () => { const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) expect(() => { result.current.queryPlugins({ query: 'test', @@ -361,60 +298,76 @@ describe('useMarketplacePlugins', () => { }) }) -// ================================ -// 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()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) result.current.queryPlugins({ query: 'test', category: 'tool', }) - expect(result.current).toBeDefined() + await waitFor(() => { + expect(result.current.plugins).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 { postMarketplace } = await import('@/service/base') + vi.mocked(postMarketplace) + .mockResolvedValueOnce({ + data: { + plugins: [ + { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, + { type: 'plugin', org: 'test', name: 'plugin2', tags: [] }, + ], + total: 100, + }, + }) + .mockResolvedValueOnce({ + data: { + plugins: [{ type: 'plugin', org: 'test', name: 'plugin3', tags: [] }], + total: 100, + }, + }) const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) - result.current.queryPlugins({ query: 'search' }) - expect(result.current.page).toBe(2) + result.current.queryPlugins({ query: 'search', page_size: 40 }) + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + expect(result.current.page).toBe(1) + expect(result.current.hasNextPage).toBe(true) + }) + + await act(async () => { + await result.current.fetchNextPage() + }) + await waitFor(() => { + 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()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) expect(result.current.total).toBeUndefined() }) it('should directly test queryFn execution', async () => { const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) result.current.queryPlugins({ query: 'direct test', @@ -424,82 +377,98 @@ describe('Hooks queryFn Coverage', () => { page_size: 40, }) - if (capturedInfiniteQueryFn) { - const controller = new AbortController() - const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal }) - expect(response).toBeDefined() - } + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) }) it('should test queryFn with bundle type', async () => { const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) 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() - } + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) }) it('should test queryFn error handling', async () => { mockPostMarketplaceShouldFail = true const { useMarketplacePlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) 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') - } + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) + expect(result.current.plugins).toEqual([]) + expect(result.current.total).toBe(0) mockPostMarketplaceShouldFail = false }) it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('../hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper }) result.current.queryMarketplaceCollectionsAndPlugins({ condition: 'category=tool', }) - if (capturedQueryFn) { - const controller = new AbortController() - const response = await capturedQueryFn({ signal: controller.signal }) - expect(response).toBeDefined() - } + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(result.current.marketplaceCollections).toBeDefined() + expect(result.current.marketplaceCollectionPluginsMap).toBeDefined() }) - it('should test getNextPageParam directly', async () => { + it('should test getNextPageParam via fetchNextPage behavior', async () => { + const { postMarketplace } = await import('@/service/base') + vi.mocked(postMarketplace) + .mockResolvedValueOnce({ + data: { plugins: [], total: 100 }, + }) + .mockResolvedValueOnce({ + data: { plugins: [], total: 100 }, + }) + .mockResolvedValueOnce({ + data: { plugins: [], total: 100 }, + }) + const { useMarketplacePlugins } = await import('../hooks') - renderHook(() => useMarketplacePlugins()) + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper }) - if (capturedGetNextPageParam) { - const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 }) - expect(nextPage).toBe(2) + result.current.queryPlugins({ query: 'test', page_size: 40 }) - const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 }) - expect(noMorePages).toBeUndefined() + await waitFor(() => { + expect(result.current.hasNextPage).toBe(true) + expect(result.current.page).toBe(1) + }) - const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 }) - expect(atBoundary).toBeUndefined() - } + result.current.fetchNextPage() + await waitFor(() => { + expect(result.current.hasNextPage).toBe(true) + expect(result.current.page).toBe(2) + }) + + result.current.fetchNextPage() + await waitFor(() => { + expect(result.current.hasNextPage).toBe(false) + expect(result.current.page).toBe(3) + }) }) }) -// ================================ -// useMarketplaceContainerScroll Tests -// ================================ describe('useMarketplaceContainerScroll', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx new file mode 100644 index 0000000000..ad1e208a2f --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx @@ -0,0 +1,122 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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}`, +})) + +const mockCollections = vi.fn() +const mockCollectionPlugins = vi.fn() + +vi.mock('@/service/client', () => ({ + marketplaceClient: { + collections: (...args: unknown[]) => mockCollections(...args), + collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args), + }, + marketplaceQuery: { + collections: { + queryKey: (params: unknown) => ['marketplace', 'collections', params], + }, + }, +})) + +let serverQueryClient: QueryClient + +vi.mock('@/context/query-client-server', () => ({ + getQueryClientServer: () => serverQueryClient, +})) + +describe('HydrateQueryClient', () => { + beforeEach(() => { + vi.clearAllMocks() + serverQueryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + mockCollections.mockResolvedValue({ + data: { collections: [] }, + }) + mockCollectionPlugins.mockResolvedValue({ + data: { plugins: [] }, + }) + }) + + it('should render children within HydrationBoundary', async () => { + const { HydrateQueryClient } = await import('../hydration-server') + + const element = await HydrateQueryClient({ + searchParams: undefined, + children: <div data-testid="child">Child Content</div>, + }) + + const renderClient = new QueryClient() + const { getByText } = render( + <QueryClientProvider client={renderClient}> + {element as React.ReactElement} + </QueryClientProvider>, + ) + expect(getByText('Child Content')).toBeInTheDocument() + }) + + it('should not prefetch when searchParams is undefined', async () => { + const { HydrateQueryClient } = await import('../hydration-server') + + await HydrateQueryClient({ + searchParams: undefined, + children: <div>Child</div>, + }) + + expect(mockCollections).not.toHaveBeenCalled() + }) + + it('should prefetch when category has collections (all)', async () => { + const { HydrateQueryClient } = await import('../hydration-server') + + await HydrateQueryClient({ + searchParams: Promise.resolve({ category: 'all' }), + children: <div>Child</div>, + }) + + expect(mockCollections).toHaveBeenCalled() + }) + + it('should prefetch when category has collections (tool)', async () => { + const { HydrateQueryClient } = await import('../hydration-server') + + await HydrateQueryClient({ + searchParams: Promise.resolve({ category: 'tool' }), + children: <div>Child</div>, + }) + + expect(mockCollections).toHaveBeenCalled() + }) + + it('should not prefetch when category does not have collections (model)', async () => { + const { HydrateQueryClient } = await import('../hydration-server') + + await HydrateQueryClient({ + searchParams: Promise.resolve({ category: 'model' }), + children: <div>Child</div>, + }) + + expect(mockCollections).not.toHaveBeenCalled() + }) + + it('should not prefetch when category does not have collections (bundle)', async () => { + const { HydrateQueryClient } = await import('../hydration-server') + + await HydrateQueryClient({ + searchParams: Promise.resolve({ category: 'bundle' }), + children: <div>Child</div>, + }) + + expect(mockCollections).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/__tests__/index.spec.tsx index 458d444370..e5a90801a5 100644 --- a/web/app/components/plugins/marketplace/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/index.spec.tsx @@ -1,15 +1,95 @@ -import { describe, it } from 'vitest' +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' -// The Marketplace index component is an async Server Component -// that cannot be unit tested in jsdom. It is covered by integration tests. -// -// All sub-module tests have been moved to dedicated spec files: -// - constants.spec.ts (DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD, PLUGIN_TYPE_SEARCH_MAP) -// - utils.spec.ts (getPluginIconInMarketplace, getFormattedPlugin, getPluginLinkInMarketplace, etc.) -// - hooks.spec.tsx (useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceContainerScroll) +vi.mock('@/context/query-client', () => ({ + TanstackQueryInitializer: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="tanstack-initializer">{children}</div> + ), +})) -describe('Marketplace index', () => { - it('should be covered by dedicated sub-module specs', () => { - // Placeholder to document the split +vi.mock('../hydration-server', () => ({ + HydrateQueryClient: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="hydration-client">{children}</div> + ), +})) + +vi.mock('../description', () => ({ + default: () => <div data-testid="description">Description</div>, +})) + +vi.mock('../list/list-wrapper', () => ({ + default: ({ showInstallButton }: { showInstallButton: boolean }) => ( + <div data-testid="list-wrapper" data-show-install={showInstallButton}>ListWrapper</div> + ), +})) + +vi.mock('../sticky-search-and-switch-wrapper', () => ({ + default: ({ pluginTypeSwitchClassName }: { pluginTypeSwitchClassName?: string }) => ( + <div data-testid="sticky-wrapper" data-classname={pluginTypeSwitchClassName}>StickyWrapper</div> + ), +})) + +describe('Marketplace', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should export a default async component', async () => { + const mod = await import('../index') + expect(mod.default).toBeDefined() + expect(typeof mod.default).toBe('function') + }) + + it('should render all child components with default props', async () => { + const Marketplace = (await import('../index')).default + const element = await Marketplace({}) + + const { getByTestId } = render(element as React.ReactElement) + + expect(getByTestId('tanstack-initializer')).toBeInTheDocument() + expect(getByTestId('hydration-client')).toBeInTheDocument() + expect(getByTestId('description')).toBeInTheDocument() + expect(getByTestId('sticky-wrapper')).toBeInTheDocument() + expect(getByTestId('list-wrapper')).toBeInTheDocument() + }) + + it('should pass showInstallButton=true by default to ListWrapper', async () => { + const Marketplace = (await import('../index')).default + const element = await Marketplace({}) + + const { getByTestId } = render(element as React.ReactElement) + + const listWrapper = getByTestId('list-wrapper') + expect(listWrapper.getAttribute('data-show-install')).toBe('true') + }) + + it('should pass showInstallButton=false when specified', async () => { + const Marketplace = (await import('../index')).default + const element = await Marketplace({ showInstallButton: false }) + + const { getByTestId } = render(element as React.ReactElement) + + const listWrapper = getByTestId('list-wrapper') + expect(listWrapper.getAttribute('data-show-install')).toBe('false') + }) + + it('should pass pluginTypeSwitchClassName to StickySearchAndSwitchWrapper', async () => { + const Marketplace = (await import('../index')).default + const element = await Marketplace({ pluginTypeSwitchClassName: 'top-14' }) + + const { getByTestId } = render(element as React.ReactElement) + + const stickyWrapper = getByTestId('sticky-wrapper') + expect(stickyWrapper.getAttribute('data-classname')).toBe('top-14') + }) + + it('should render without pluginTypeSwitchClassName', async () => { + const Marketplace = (await import('../index')).default + const element = await Marketplace({}) + + const { getByTestId } = render(element as React.ReactElement) + + const stickyWrapper = getByTestId('sticky-wrapper') + expect(stickyWrapper.getAttribute('data-classname')).toBeNull() }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx b/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx new file mode 100644 index 0000000000..6bb075410e --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx @@ -0,0 +1,124 @@ +import type { UrlUpdateEvent } from 'nuqs/adapters/testing' +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { Provider as JotaiProvider } from 'jotai' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import PluginTypeSwitch from '../plugin-type-switch' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record<string, string> = { + 'category.all': 'All', + 'category.models': 'Models', + 'category.tools': 'Tools', + 'category.datasources': 'Data Sources', + 'category.triggers': 'Triggers', + 'category.agents': 'Agents', + 'category.extensions': 'Extensions', + 'category.bundles': 'Bundles', + } + return map[key] || key + }, + }), +})) + +const createWrapper = (searchParams = '') => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() + const Wrapper = ({ children }: { children: ReactNode }) => ( + <JotaiProvider> + <NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}> + {children} + </NuqsTestingAdapter> + </JotaiProvider> + ) + return { Wrapper, onUrlUpdate } +} + +describe('PluginTypeSwitch', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render all category options', () => { + const { Wrapper } = createWrapper() + render(<PluginTypeSwitch />, { wrapper: Wrapper }) + + expect(screen.getByText('All')).toBeInTheDocument() + expect(screen.getByText('Models')).toBeInTheDocument() + expect(screen.getByText('Tools')).toBeInTheDocument() + expect(screen.getByText('Data Sources')).toBeInTheDocument() + expect(screen.getByText('Triggers')).toBeInTheDocument() + expect(screen.getByText('Agents')).toBeInTheDocument() + expect(screen.getByText('Extensions')).toBeInTheDocument() + expect(screen.getByText('Bundles')).toBeInTheDocument() + }) + + it('should apply active styling to current category', () => { + const { Wrapper } = createWrapper('?category=all') + render(<PluginTypeSwitch />, { wrapper: Wrapper }) + + const allButton = screen.getByText('All').closest('div') + expect(allButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active') + }) + + it('should apply custom className', () => { + const { Wrapper } = createWrapper() + const { container } = render(<PluginTypeSwitch className="custom-class" />, { wrapper: Wrapper }) + + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv.className).toContain('custom-class') + }) + + it('should update category when option is clicked', () => { + const { Wrapper } = createWrapper('?category=all') + render(<PluginTypeSwitch />, { wrapper: Wrapper }) + + // Click on Models option — should not throw + expect(() => fireEvent.click(screen.getByText('Models'))).not.toThrow() + }) + + it('should handle clicking on category with collections (Tools)', () => { + const { Wrapper } = createWrapper('?category=model') + render(<PluginTypeSwitch />, { wrapper: Wrapper }) + + // Click on "Tools" which has collections → setSearchMode(null) + expect(() => fireEvent.click(screen.getByText('Tools'))).not.toThrow() + }) + + it('should handle clicking on category without collections (Models)', () => { + const { Wrapper } = createWrapper('?category=all') + render(<PluginTypeSwitch />, { wrapper: Wrapper }) + + // Click on "Models" which does NOT have collections → no setSearchMode call + expect(() => fireEvent.click(screen.getByText('Models'))).not.toThrow() + }) + + it('should handle clicking on bundles', () => { + const { Wrapper } = createWrapper('?category=all') + render(<PluginTypeSwitch />, { wrapper: Wrapper }) + + expect(() => fireEvent.click(screen.getByText('Bundles'))).not.toThrow() + }) + + it('should handle clicking on each category', () => { + const { Wrapper } = createWrapper('?category=all') + render(<PluginTypeSwitch />, { wrapper: Wrapper }) + + const categories = ['All', 'Models', 'Tools', 'Data Sources', 'Triggers', 'Agents', 'Extensions', 'Bundles'] + categories.forEach((category) => { + expect(() => fireEvent.click(screen.getByText(category))).not.toThrow() + }) + }) + + it('should render icons for categories that have them', () => { + const { Wrapper } = createWrapper() + const { container } = render(<PluginTypeSwitch />, { wrapper: Wrapper }) + + // "All" has no icon (icon: null), others should have SVG icons + const svgs = container.querySelectorAll('svg') + // 7 categories with icons (all categories except "All") + expect(svgs.length).toBeGreaterThanOrEqual(7) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/query.spec.tsx b/web/app/components/plugins/marketplace/__tests__/query.spec.tsx new file mode 100644 index 0000000000..80d8e6a932 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/query.spec.tsx @@ -0,0 +1,220 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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}`, +})) + +const mockCollections = vi.fn() +const mockCollectionPlugins = 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), + }, + marketplaceQuery: { + collections: { + queryKey: (params: unknown) => ['marketplace', 'collections', params], + }, + searchAdvanced: { + queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params], + }, + }, +})) + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + }, + }) + +const createWrapper = () => { + const queryClient = createTestQueryClient() + const Wrapper = ({ children }: { children: ReactNode }) => ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ) + return { Wrapper, queryClient } +} + +describe('useMarketplaceCollectionsAndPlugins', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should fetch collections and plugins data', async () => { + const mockCollectionData = [ + { name: 'col-1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + ] + const mockPluginData = [ + { type: 'plugin', org: 'test', name: 'plugin1', tags: [] }, + ] + + mockCollections.mockResolvedValue({ data: { collections: mockCollectionData } }) + mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } }) + + const { useMarketplaceCollectionsAndPlugins } = await import('../query') + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplaceCollectionsAndPlugins({ condition: 'category=tool', type: 'plugin' }), + { wrapper: Wrapper }, + ) + + await waitFor(() => { + expect(result.current.data).toBeDefined() + }) + + expect(result.current.data?.marketplaceCollections).toBeDefined() + expect(result.current.data?.marketplaceCollectionPluginsMap).toBeDefined() + }) + + it('should handle empty collections params', async () => { + mockCollections.mockResolvedValue({ data: { collections: [] } }) + + const { useMarketplaceCollectionsAndPlugins } = await import('../query') + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplaceCollectionsAndPlugins({}), + { wrapper: Wrapper }, + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + }) +}) + +describe('useMarketplacePlugins', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should not fetch when queryParams is undefined', async () => { + const { useMarketplacePlugins } = await import('../query') + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePlugins(undefined), + { wrapper: Wrapper }, + ) + + // enabled is false, so should not fetch + expect(result.current.data).toBeUndefined() + expect(mockSearchAdvanced).not.toHaveBeenCalled() + }) + + it('should fetch plugins when queryParams is provided', async () => { + mockSearchAdvanced.mockResolvedValue({ + data: { + plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }], + total: 1, + }, + }) + + const { useMarketplacePlugins } = await import('../query') + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePlugins({ + query: 'test', + sort_by: 'install_count', + sort_order: 'DESC', + category: 'tool', + tags: [], + type: 'plugin', + }), + { wrapper: Wrapper }, + ) + + await waitFor(() => { + expect(result.current.data).toBeDefined() + }) + + expect(result.current.data?.pages).toHaveLength(1) + expect(result.current.data?.pages[0].plugins).toHaveLength(1) + }) + + it('should handle bundle type in query params', async () => { + mockSearchAdvanced.mockResolvedValue({ + data: { + bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [] }], + total: 1, + }, + }) + + const { useMarketplacePlugins } = await import('../query') + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePlugins({ + query: 'bundle', + type: 'bundle', + }), + { wrapper: Wrapper }, + ) + + await waitFor(() => { + expect(result.current.data).toBeDefined() + }) + }) + + it('should handle API error gracefully', async () => { + mockSearchAdvanced.mockRejectedValue(new Error('Network error')) + + const { useMarketplacePlugins } = await import('../query') + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePlugins({ + query: 'fail', + }), + { wrapper: Wrapper }, + ) + + await waitFor(() => { + expect(result.current.data).toBeDefined() + }) + + expect(result.current.data?.pages[0].plugins).toEqual([]) + expect(result.current.data?.pages[0].total).toBe(0) + }) + + it('should determine next page correctly via getNextPageParam', async () => { + // Return enough data that there would be a next page + mockSearchAdvanced.mockResolvedValue({ + data: { + plugins: Array.from({ length: 40 }, (_, i) => ({ + type: 'plugin', + org: 'test', + name: `p${i}`, + tags: [], + })), + total: 100, + }, + }) + + const { useMarketplacePlugins } = await import('../query') + const { Wrapper } = createWrapper() + const { result } = renderHook( + () => useMarketplacePlugins({ + query: 'paginated', + page_size: 40, + }), + { wrapper: Wrapper }, + ) + + await waitFor(() => { + expect(result.current.hasNextPage).toBe(true) + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx new file mode 100644 index 0000000000..4177c9b2b7 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx @@ -0,0 +1,267 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { Provider as JotaiProvider } from 'jotai' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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}`, +})) + +const mockCollections = vi.fn() +const mockCollectionPlugins = 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), + }, + marketplaceQuery: { + collections: { + queryKey: (params: unknown) => ['marketplace', 'collections', params], + }, + searchAdvanced: { + queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params], + }, + }, +})) + +const createWrapper = (searchParams = '') => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + }, + }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + <JotaiProvider> + <QueryClientProvider client={queryClient}> + <NuqsTestingAdapter searchParams={searchParams}> + {children} + </NuqsTestingAdapter> + </QueryClientProvider> + </JotaiProvider> + ) + return { Wrapper, queryClient } +} + +describe('useMarketplaceData', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockCollections.mockResolvedValue({ + data: { + collections: [ + { name: 'col-1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + ], + }, + }) + mockCollectionPlugins.mockResolvedValue({ + data: { + plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }], + }, + }) + mockSearchAdvanced.mockResolvedValue({ + data: { + plugins: [{ type: 'plugin', org: 'test', name: 'p2', tags: [] }], + total: 1, + }, + }) + }) + + it('should return initial state with loading and collections data', async () => { + const { useMarketplaceData } = await import('../state') + const { Wrapper } = createWrapper('?category=all') + + // Create a mock container for scroll + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.marketplaceCollections).toBeDefined() + expect(result.current.marketplaceCollectionPluginsMap).toBeDefined() + expect(result.current.page).toBeDefined() + expect(result.current.isFetchingNextPage).toBe(false) + + document.body.removeChild(container) + }) + + it('should return search mode data when search text is present', async () => { + const { useMarketplaceData } = await import('../state') + const { Wrapper } = createWrapper('?category=all&q=test') + + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.plugins).toBeDefined() + expect(result.current.pluginsTotal).toBeDefined() + + document.body.removeChild(container) + }) + + it('should return plugins undefined in collection mode (not search mode)', async () => { + const { useMarketplaceData } = await import('../state') + // "all" category with no search → collection mode + const { Wrapper } = createWrapper('?category=all') + + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + // In non-search mode, plugins should be undefined since useMarketplacePlugins is disabled + expect(result.current.plugins).toBeUndefined() + + document.body.removeChild(container) + }) + + it('should enable search for category without collections (e.g. model)', async () => { + const { useMarketplaceData } = await import('../state') + const { Wrapper } = createWrapper('?category=model') + + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + // "model" triggers search mode automatically + expect(result.current.plugins).toBeDefined() + + document.body.removeChild(container) + }) + + it('should trigger scroll pagination via handlePageChange callback', async () => { + // Return enough data to indicate hasNextPage (40 of 200 total) + mockSearchAdvanced.mockResolvedValue({ + data: { + plugins: Array.from({ length: 40 }, (_, i) => ({ + type: 'plugin', + org: 'test', + name: `p${i}`, + tags: [], + })), + total: 200, + }, + }) + + const { useMarketplaceData } = await import('../state') + // Use "model" to force search mode + const { Wrapper } = createWrapper('?category=model') + + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + Object.defineProperty(container, 'scrollTop', { value: 900, writable: true, configurable: true }) + Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true }) + Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true }) + + const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper }) + + // Wait for data to fully load (isFetching becomes false, plugins become available) + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + expect(result.current.plugins!.length).toBeGreaterThan(0) + }) + + // Trigger scroll event to invoke handlePageChange + const scrollEvent = new Event('scroll') + Object.defineProperty(scrollEvent, 'target', { value: container }) + container.dispatchEvent(scrollEvent) + + document.body.removeChild(container) + }) + + it('should handle tags filter in search mode', async () => { + const { useMarketplaceData } = await import('../state') + // tags in URL triggers search mode + const { Wrapper } = createWrapper('?category=all&tags=search') + + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + // Tags triggers search mode even with "all" category + expect(result.current.plugins).toBeDefined() + + document.body.removeChild(container) + }) + + it('should not fetch next page when scroll fires but no more data', async () => { + // Return only 2 items with total=2 → no more pages + mockSearchAdvanced.mockResolvedValue({ + data: { + plugins: [ + { type: 'plugin', org: 'test', name: 'p1', tags: [] }, + { type: 'plugin', org: 'test', name: 'p2', tags: [] }, + ], + total: 2, + }, + }) + + const { useMarketplaceData } = await import('../state') + const { Wrapper } = createWrapper('?category=model') + + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + Object.defineProperty(container, 'scrollTop', { value: 900, writable: true, configurable: true }) + Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true }) + Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true }) + + const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper }) + + await waitFor(() => { + expect(result.current.plugins).toBeDefined() + }) + + // Scroll fires but hasNextPage is false → handlePageChange does nothing + const scrollEvent = new Event('scroll') + Object.defineProperty(scrollEvent, 'target', { value: container }) + container.dispatchEvent(scrollEvent) + + // isFetchingNextPage should remain false + expect(result.current.isFetchingNextPage).toBe(false) + + document.body.removeChild(container) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/sticky-search-and-switch-wrapper.spec.tsx b/web/app/components/plugins/marketplace/__tests__/sticky-search-and-switch-wrapper.spec.tsx new file mode 100644 index 0000000000..1311adb508 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/sticky-search-and-switch-wrapper.spec.tsx @@ -0,0 +1,79 @@ +import type { ReactNode } from 'react' +import { render } from '@testing-library/react' +import { Provider as JotaiProvider } from 'jotai' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import StickySearchAndSwitchWrapper from '../sticky-search-and-switch-wrapper' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock child components to isolate wrapper logic +vi.mock('../plugin-type-switch', () => ({ + default: () => <div data-testid="plugin-type-switch">PluginTypeSwitch</div>, +})) + +vi.mock('../search-box/search-box-wrapper', () => ({ + default: () => <div data-testid="search-box-wrapper">SearchBoxWrapper</div>, +})) + +const Wrapper = ({ children }: { children: ReactNode }) => ( + <JotaiProvider> + <NuqsTestingAdapter> + {children} + </NuqsTestingAdapter> + </JotaiProvider> +) + +describe('StickySearchAndSwitchWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render SearchBoxWrapper and PluginTypeSwitch', () => { + const { getByTestId } = render( + <StickySearchAndSwitchWrapper />, + { wrapper: Wrapper }, + ) + + expect(getByTestId('search-box-wrapper')).toBeInTheDocument() + expect(getByTestId('plugin-type-switch')).toBeInTheDocument() + }) + + it('should not apply sticky class when no pluginTypeSwitchClassName', () => { + const { container } = render( + <StickySearchAndSwitchWrapper />, + { wrapper: Wrapper }, + ) + + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv.className).toContain('mt-4') + expect(outerDiv.className).not.toContain('sticky') + }) + + it('should apply sticky class when pluginTypeSwitchClassName contains top-', () => { + const { container } = render( + <StickySearchAndSwitchWrapper pluginTypeSwitchClassName="top-10" />, + { wrapper: Wrapper }, + ) + + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv.className).toContain('sticky') + expect(outerDiv.className).toContain('z-10') + expect(outerDiv.className).toContain('top-10') + }) + + it('should not apply sticky class when pluginTypeSwitchClassName does not contain top-', () => { + const { container } = render( + <StickySearchAndSwitchWrapper pluginTypeSwitchClassName="custom-class" />, + { wrapper: Wrapper }, + ) + + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv.className).not.toContain('sticky') + expect(outerDiv.className).toContain('custom-class') + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts index 91beed2630..ad0f899de4 100644 --- a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts +++ b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts @@ -315,3 +315,165 @@ describe('getCollectionsParams', () => { }) }) }) + +describe('getMarketplacePlugins', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return empty result when queryParams is undefined', async () => { + const { getMarketplacePlugins } = await import('../utils') + const result = await getMarketplacePlugins(undefined, 1) + + expect(result).toEqual({ + plugins: [], + total: 0, + page: 1, + page_size: 40, + }) + expect(mockSearchAdvanced).not.toHaveBeenCalled() + }) + + it('should fetch plugins with valid query params', async () => { + mockSearchAdvanced.mockResolvedValueOnce({ + data: { + plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }], + total: 1, + }, + }) + + const { getMarketplacePlugins } = await import('../utils') + const result = await getMarketplacePlugins({ + query: 'test', + sort_by: 'install_count', + sort_order: 'DESC', + category: 'tool', + tags: ['search'], + type: 'plugin', + page_size: 20, + }, 1) + + expect(result.plugins).toHaveLength(1) + expect(result.total).toBe(1) + expect(result.page).toBe(1) + expect(result.page_size).toBe(20) + }) + + it('should use bundles endpoint when type is bundle', async () => { + mockSearchAdvanced.mockResolvedValueOnce({ + data: { + bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }], + total: 1, + }, + }) + + const { getMarketplacePlugins } = await import('../utils') + const result = await getMarketplacePlugins({ + query: 'bundle', + type: 'bundle', + }, 1) + + expect(result.plugins).toHaveLength(1) + const call = mockSearchAdvanced.mock.calls[0] + expect(call[0].params.kind).toBe('bundles') + }) + + it('should use empty category when category is all', async () => { + mockSearchAdvanced.mockResolvedValueOnce({ + data: { plugins: [], total: 0 }, + }) + + const { getMarketplacePlugins } = await import('../utils') + await getMarketplacePlugins({ + query: 'test', + category: 'all', + }, 1) + + const call = mockSearchAdvanced.mock.calls[0] + expect(call[0].body.category).toBe('') + }) + + it('should handle API error and return empty result', async () => { + mockSearchAdvanced.mockRejectedValueOnce(new Error('API error')) + + const { getMarketplacePlugins } = await import('../utils') + const result = await getMarketplacePlugins({ + query: 'fail', + }, 2) + + expect(result).toEqual({ + plugins: [], + total: 0, + page: 2, + page_size: 40, + }) + }) + + it('should pass abort signal when provided', async () => { + mockSearchAdvanced.mockResolvedValueOnce({ + data: { plugins: [], total: 0 }, + }) + + const controller = new AbortController() + const { getMarketplacePlugins } = await import('../utils') + await getMarketplacePlugins({ query: 'test' }, 1, controller.signal) + + const call = mockSearchAdvanced.mock.calls[0] + expect(call[1]).toMatchObject({ signal: controller.signal }) + }) + + it('should default page_size to 40 when not provided', async () => { + mockSearchAdvanced.mockResolvedValueOnce({ + data: { plugins: [], total: 0 }, + }) + + const { getMarketplacePlugins } = await import('../utils') + const result = await getMarketplacePlugins({ query: 'test' }, 1) + + expect(result.page_size).toBe(40) + }) + + it('should handle response with bundles fallback to plugins fallback to empty', async () => { + // No bundles and no plugins in response + mockSearchAdvanced.mockResolvedValueOnce({ + data: { total: 0 }, + }) + + const { getMarketplacePlugins } = await import('../utils') + const result = await getMarketplacePlugins({ query: 'test' }, 1) + + expect(result.plugins).toEqual([]) + }) +}) + +// ================================ +// Edge cases for ||/optional chaining branches +// ================================ +describe('Utils branch edge cases', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should handle collectionPlugins returning undefined plugins', async () => { + mockCollectionPlugins.mockResolvedValueOnce({ + data: { plugins: undefined }, + }) + + const { getMarketplacePluginsByCollectionId } = await import('../utils') + const result = await getMarketplacePluginsByCollectionId('test-collection') + + expect(result).toEqual([]) + }) + + it('should handle collections returning undefined collections list', async () => { + mockCollections.mockResolvedValueOnce({ + data: { collections: undefined }, + }) + + const { getMarketplaceCollectionsAndPlugins } = await import('../utils') + const result = await getMarketplaceCollectionsAndPlugins() + + // undefined || [] evaluates to [], so empty array is expected + expect(result.marketplaceCollections).toEqual([]) + }) +}) diff --git a/web/app/components/plugins/marketplace/hooks.spec.tsx b/web/app/components/plugins/marketplace/hooks.spec.tsx deleted file mode 100644 index 89abbe5025..0000000000 --- a/web/app/components/plugins/marketplace/hooks.spec.tsx +++ /dev/null @@ -1,597 +0,0 @@ -import { render, renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -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) - }) -}) diff --git a/web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx index 16b5eb580d..d259b27c30 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/index.spec.tsx @@ -1,140 +1,7 @@ -import type { ReactNode } from 'react' -import type { Credential, PluginPayload } from '../types' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { AuthCategory, CredentialTypeEnum } from '../types' -const mockGetPluginCredentialInfo = vi.fn() -const mockDeletePluginCredential = vi.fn() -const mockSetPluginDefaultCredential = vi.fn() -const mockUpdatePluginCredential = vi.fn() -const mockInvalidPluginCredentialInfo = vi.fn() -const mockGetPluginOAuthUrl = vi.fn() -const mockGetPluginOAuthClientSchema = vi.fn() -const mockSetPluginOAuthCustomClient = vi.fn() -const mockDeletePluginOAuthCustomClient = vi.fn() -const mockInvalidPluginOAuthClientSchema = vi.fn() -const mockAddPluginCredential = vi.fn() -const mockGetPluginCredentialSchema = vi.fn() -const mockInvalidToolsByType = vi.fn() - -vi.mock('@/service/use-plugins-auth', () => ({ - useGetPluginCredentialInfo: (url: string) => ({ - data: url ? mockGetPluginCredentialInfo() : undefined, - isLoading: false, - }), - useDeletePluginCredential: () => ({ - mutateAsync: mockDeletePluginCredential, - }), - useSetPluginDefaultCredential: () => ({ - mutateAsync: mockSetPluginDefaultCredential, - }), - useUpdatePluginCredential: () => ({ - mutateAsync: mockUpdatePluginCredential, - }), - useInvalidPluginCredentialInfo: () => mockInvalidPluginCredentialInfo, - useGetPluginOAuthUrl: () => ({ - mutateAsync: mockGetPluginOAuthUrl, - }), - useGetPluginOAuthClientSchema: () => ({ - data: mockGetPluginOAuthClientSchema(), - isLoading: false, - }), - useSetPluginOAuthCustomClient: () => ({ - mutateAsync: mockSetPluginOAuthCustomClient, - }), - useDeletePluginOAuthCustomClient: () => ({ - mutateAsync: mockDeletePluginOAuthCustomClient, - }), - useInvalidPluginOAuthClientSchema: () => mockInvalidPluginOAuthClientSchema, - useAddPluginCredential: () => ({ - mutateAsync: mockAddPluginCredential, - }), - useGetPluginCredentialSchema: () => ({ - data: mockGetPluginCredentialSchema(), - isLoading: false, - }), -})) - -vi.mock('@/service/use-tools', () => ({ - useInvalidToolsByType: () => mockInvalidToolsByType, -})) - -const mockIsCurrentWorkspaceManager = vi.fn() -vi.mock('@/context/app-context', () => ({ - useAppContext: () => ({ - isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), - }), -})) - -const mockNotify = vi.fn() -vi.mock('@/app/components/base/toast', () => ({ - useToastContext: () => ({ - notify: mockNotify, - }), -})) - -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(), -})) - -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, -}) - -const _createCredentialList = (count: number, overrides: Partial<Credential>[] = []): Credential[] => { - return Array.from({ length: count }, (_, i) => createCredential({ - id: `credential-${i}`, - name: `Credential ${i}`, - is_default: i === 0, - ...overrides[i], - })) -} - -describe('Index Exports', () => { +describe('plugin-auth index exports', () => { it('should export all required components and hooks', async () => { const exports = await import('../index') @@ -144,104 +11,23 @@ describe('Index Exports', () => { expect(exports.Authorized).toBeDefined() expect(exports.AuthorizedInDataSourceNode).toBeDefined() expect(exports.AuthorizedInNode).toBeDefined() - expect(exports.usePluginAuth).toBeDefined() expect(exports.PluginAuth).toBeDefined() expect(exports.PluginAuthInAgent).toBeDefined() expect(exports.PluginAuthInDataSourceNode).toBeDefined() - }, 15000) - - it('should export AuthCategory enum', async () => { - const exports = await import('../index') - - expect(exports.AuthCategory).toBeDefined() - expect(exports.AuthCategory.tool).toBe('tool') - expect(exports.AuthCategory.datasource).toBe('datasource') - expect(exports.AuthCategory.model).toBe('model') - expect(exports.AuthCategory.trigger).toBe('trigger') - }, 15000) - - it('should export CredentialTypeEnum', async () => { - const exports = await import('../index') - - expect(exports.CredentialTypeEnum).toBeDefined() - expect(exports.CredentialTypeEnum.OAUTH2).toBe('oauth2') - expect(exports.CredentialTypeEnum.API_KEY).toBe('api-key') - }, 15000) -}) - -describe('Types', () => { - describe('AuthCategory enum', () => { - it('should have correct values', () => { - expect(AuthCategory.tool).toBe('tool') - expect(AuthCategory.datasource).toBe('datasource') - expect(AuthCategory.model).toBe('model') - expect(AuthCategory.trigger).toBe('trigger') - }) - - it('should have exactly 4 categories', () => { - const values = Object.values(AuthCategory) - expect(values).toHaveLength(4) - }) + expect(exports.usePluginAuth).toBeDefined() }) - describe('CredentialTypeEnum', () => { - it('should have correct values', () => { - expect(CredentialTypeEnum.OAUTH2).toBe('oauth2') - expect(CredentialTypeEnum.API_KEY).toBe('api-key') - }) - - it('should have exactly 2 types', () => { - const values = Object.values(CredentialTypeEnum) - expect(values).toHaveLength(2) - }) + it('should re-export AuthCategory enum with correct values', () => { + expect(Object.values(AuthCategory)).toHaveLength(4) + expect(AuthCategory.tool).toBe('tool') + expect(AuthCategory.datasource).toBe('datasource') + expect(AuthCategory.model).toBe('model') + expect(AuthCategory.trigger).toBe('trigger') }) - describe('Credential type', () => { - it('should allow creating valid credentials', () => { - const credential: Credential = { - id: 'test-id', - name: 'Test', - provider: 'test-provider', - is_default: true, - } - expect(credential.id).toBe('test-id') - expect(credential.is_default).toBe(true) - }) - - it('should allow optional fields', () => { - const credential: Credential = { - id: 'test-id', - name: 'Test', - provider: 'test-provider', - is_default: false, - credential_type: CredentialTypeEnum.API_KEY, - credentials: { key: 'value' }, - isWorkspaceDefault: true, - from_enterprise: false, - not_allowed_to_use: false, - } - expect(credential.credential_type).toBe(CredentialTypeEnum.API_KEY) - expect(credential.isWorkspaceDefault).toBe(true) - }) - }) - - describe('PluginPayload type', () => { - it('should allow creating valid plugin payload', () => { - const payload: PluginPayload = { - category: AuthCategory.tool, - provider: 'test-provider', - } - expect(payload.category).toBe(AuthCategory.tool) - }) - - it('should allow optional fields', () => { - const payload: PluginPayload = { - category: AuthCategory.datasource, - provider: 'test-provider', - providerType: 'builtin', - detail: undefined, - } - expect(payload.providerType).toBe('builtin') - }) + it('should re-export CredentialTypeEnum with correct values', () => { + expect(Object.values(CredentialTypeEnum)).toHaveLength(2) + expect(CredentialTypeEnum.OAUTH2).toBe('oauth2') + expect(CredentialTypeEnum.API_KEY).toBe('api-key') }) }) diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx index 511f3a25a3..bd30b782d3 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx @@ -92,7 +92,7 @@ describe('PluginAuth', () => { expect(screen.queryByTestId('authorized')).not.toBeInTheDocument() }) - it('applies className when not authorized', () => { + it('renders with className wrapper when not authorized', () => { mockUsePluginAuth.mockReturnValue({ isAuthorized: false, canOAuth: false, @@ -104,10 +104,10 @@ describe('PluginAuth', () => { }) const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />) - expect((container.firstChild as HTMLElement).className).toContain('custom-class') + expect(container.innerHTML).toContain('custom-class') }) - it('does not apply className when authorized', () => { + it('does not render className wrapper when authorized', () => { mockUsePluginAuth.mockReturnValue({ isAuthorized: true, canOAuth: false, @@ -119,7 +119,7 @@ describe('PluginAuth', () => { }) const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />) - expect((container.firstChild as HTMLElement).className).not.toContain('custom-class') + expect(container.innerHTML).not.toContain('custom-class') }) it('passes pluginPayload.provider to usePluginAuth', () => { diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx index fb7eb4bd12..5a705b14eb 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx @@ -96,7 +96,7 @@ describe('Authorize', () => { it('should render nothing when canOAuth and canApiKey are both false/undefined', () => { const pluginPayload = createPluginPayload() - const { container } = render( + render( <Authorize pluginPayload={pluginPayload} canOAuth={false} @@ -105,10 +105,7 @@ describe('Authorize', () => { { wrapper: createWrapper() }, ) - // No buttons should be rendered expect(screen.queryByRole('button')).not.toBeInTheDocument() - // Container should only have wrapper element - expect(container.querySelector('.flex')).toBeInTheDocument() }) it('should render only OAuth button when canOAuth is true and canApiKey is false', () => { @@ -225,7 +222,7 @@ describe('Authorize', () => { // ==================== Props Testing ==================== describe('Props Testing', () => { describe('theme prop', () => { - it('should render buttons with secondary theme variant when theme is secondary', () => { + it('should render buttons when theme is secondary', () => { const pluginPayload = createPluginPayload() render( @@ -239,9 +236,7 @@ describe('Authorize', () => { ) const buttons = screen.getAllByRole('button') - buttons.forEach((button) => { - expect(button.className).toContain('btn-secondary') - }) + expect(buttons).toHaveLength(2) }) }) @@ -327,10 +322,10 @@ describe('Authorize', () => { expect(screen.getByRole('button')).toBeDisabled() }) - it('should add opacity class when notAllowCustomCredential is true', () => { + it('should disable all buttons when notAllowCustomCredential is true', () => { const pluginPayload = createPluginPayload() - const { container } = render( + render( <Authorize pluginPayload={pluginPayload} canOAuth={true} @@ -340,8 +335,8 @@ describe('Authorize', () => { { wrapper: createWrapper() }, ) - const wrappers = container.querySelectorAll('.opacity-50') - expect(wrappers.length).toBe(2) // Both OAuth and API Key wrappers + const buttons = screen.getAllByRole('button') + buttons.forEach(button => expect(button).toBeDisabled()) }) }) }) @@ -459,7 +454,7 @@ describe('Authorize', () => { expect(screen.getAllByRole('button').length).toBe(2) }) - it('should update button variant when theme changes', () => { + it('should change button styling when theme changes', () => { const pluginPayload = createPluginPayload() const { rerender } = render( @@ -471,9 +466,7 @@ describe('Authorize', () => { { wrapper: createWrapper() }, ) - const buttonPrimary = screen.getByRole('button') - // Primary theme with canOAuth=false should have primary variant - expect(buttonPrimary.className).toContain('btn-primary') + const primaryClassName = screen.getByRole('button').className rerender( <Authorize @@ -483,7 +476,8 @@ describe('Authorize', () => { />, ) - expect(screen.getByRole('button').className).toContain('btn-secondary') + const secondaryClassName = screen.getByRole('button').className + expect(primaryClassName).not.toBe(secondaryClassName) }) }) @@ -574,38 +568,10 @@ describe('Authorize', () => { expect(typeof AuthorizeDefault).toBe('object') }) - it('should not re-render wrapper when notAllowCustomCredential stays the same', () => { - const pluginPayload = createPluginPayload() - const onUpdate = vi.fn() - - const { rerender, container } = render( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - notAllowCustomCredential={false} - onUpdate={onUpdate} - />, - { wrapper: createWrapper() }, - ) - - const initialOpacityElements = container.querySelectorAll('.opacity-50').length - - rerender( - <Authorize - pluginPayload={pluginPayload} - canOAuth={true} - notAllowCustomCredential={false} - onUpdate={onUpdate} - />, - ) - - expect(container.querySelectorAll('.opacity-50').length).toBe(initialOpacityElements) - }) - - it('should update wrapper when notAllowCustomCredential changes', () => { + it('should reflect notAllowCustomCredential change via button disabled state', () => { const pluginPayload = createPluginPayload() - const { rerender, container } = render( + const { rerender } = render( <Authorize pluginPayload={pluginPayload} canOAuth={true} @@ -614,7 +580,7 @@ describe('Authorize', () => { { wrapper: createWrapper() }, ) - expect(container.querySelectorAll('.opacity-50').length).toBe(0) + expect(screen.getByRole('button')).not.toBeDisabled() rerender( <Authorize @@ -624,7 +590,7 @@ describe('Authorize', () => { />, ) - expect(container.querySelectorAll('.opacity-50').length).toBe(1) + expect(screen.getByRole('button')).toBeDisabled() }) }) diff --git a/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx index 156b20b7d9..0225c8c8c6 100644 --- a/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx @@ -1,5 +1,5 @@ import type { Credential } from '../../types' -import { fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CredentialTypeEnum } from '../../types' import Item from '../item' @@ -67,7 +67,7 @@ describe('Item Component', () => { it('should render selected icon when showSelectedIcon is true and credential is selected', () => { const credential = createCredential({ id: 'selected-id' }) - render( + const { container } = render( <Item credential={credential} showSelectedIcon={true} @@ -75,53 +75,64 @@ describe('Item Component', () => { />, ) - // RiCheckLine should be rendered - expect(document.querySelector('.text-text-accent')).toBeInTheDocument() + const svgs = container.querySelectorAll('svg') + expect(svgs.length).toBeGreaterThan(0) }) it('should not render selected icon when credential is not selected', () => { const credential = createCredential({ id: 'not-selected-id' }) - render( + const { container: selectedContainer } = render( + <Item + credential={createCredential({ id: 'sel-id' })} + showSelectedIcon={true} + selectedCredentialId="sel-id" + />, + ) + const selectedSvgCount = selectedContainer.querySelectorAll('svg').length + + cleanup() + + const { container: unselectedContainer } = render( <Item credential={credential} showSelectedIcon={true} selectedCredentialId="other-id" />, ) + const unselectedSvgCount = unselectedContainer.querySelectorAll('svg').length - // Check icon should not be visible - expect(document.querySelector('.text-text-accent')).not.toBeInTheDocument() + expect(unselectedSvgCount).toBeLessThan(selectedSvgCount) }) - it('should render with gray indicator when not_allowed_to_use is true', () => { + it('should render with disabled appearance when not_allowed_to_use is true', () => { const credential = createCredential({ not_allowed_to_use: true }) const { container } = render(<Item credential={credential} />) - // The item should have tooltip wrapper with data-state attribute for unavailable credential - const tooltipTrigger = container.querySelector('[data-state]') - expect(tooltipTrigger).toBeInTheDocument() - // The item should have disabled styles - expect(container.querySelector('.cursor-not-allowed')).toBeInTheDocument() + expect(container.querySelector('[data-state]')).toBeInTheDocument() }) - it('should apply disabled styles when disabled is true', () => { + it('should not call onItemClick when disabled is true', () => { + const onItemClick = vi.fn() const credential = createCredential() - const { container } = render(<Item credential={credential} disabled={true} />) + const { container } = render(<Item credential={credential} onItemClick={onItemClick} disabled={true} />) - const itemDiv = container.querySelector('.cursor-not-allowed') - expect(itemDiv).toBeInTheDocument() + fireEvent.click(container.firstElementChild!) + + expect(onItemClick).not.toHaveBeenCalled() }) - it('should apply disabled styles when not_allowed_to_use is true', () => { + it('should not call onItemClick when not_allowed_to_use is true', () => { + const onItemClick = vi.fn() const credential = createCredential({ not_allowed_to_use: true }) - const { container } = render(<Item credential={credential} />) + const { container } = render(<Item credential={credential} onItemClick={onItemClick} />) - const itemDiv = container.querySelector('.cursor-not-allowed') - expect(itemDiv).toBeInTheDocument() + fireEvent.click(container.firstElementChild!) + + expect(onItemClick).not.toHaveBeenCalled() }) }) @@ -135,8 +146,7 @@ describe('Item Component', () => { <Item credential={credential} onItemClick={onItemClick} />, ) - const itemDiv = container.querySelector('.group') - fireEvent.click(itemDiv!) + fireEvent.click(container.firstElementChild!) expect(onItemClick).toHaveBeenCalledWith('click-test-id') }) @@ -149,49 +159,22 @@ describe('Item Component', () => { <Item credential={credential} onItemClick={onItemClick} />, ) - const itemDiv = container.querySelector('.group') - fireEvent.click(itemDiv!) + fireEvent.click(container.firstElementChild!) expect(onItemClick).toHaveBeenCalledWith('') }) - - it('should not call onItemClick when disabled', () => { - const onItemClick = vi.fn() - const credential = createCredential() - - const { container } = render( - <Item credential={credential} onItemClick={onItemClick} disabled={true} />, - ) - - const itemDiv = container.querySelector('.group') - fireEvent.click(itemDiv!) - - expect(onItemClick).not.toHaveBeenCalled() - }) - - it('should not call onItemClick when not_allowed_to_use is true', () => { - const onItemClick = vi.fn() - const credential = createCredential({ not_allowed_to_use: true }) - - const { container } = render( - <Item credential={credential} onItemClick={onItemClick} />, - ) - - const itemDiv = container.querySelector('.group') - fireEvent.click(itemDiv!) - - expect(onItemClick).not.toHaveBeenCalled() - }) }) // ==================== Rename Mode Tests ==================== describe('Rename Mode', () => { - it('should enter rename mode when rename button is clicked', () => { - const credential = createCredential() + const renderWithRenameEnabled = (overrides: Record<string, unknown> = {}) => { + const onRename = vi.fn() + const credential = createCredential({ name: 'Original Name', ...overrides }) - const { container } = render( + const result = render( <Item credential={credential} + onRename={onRename} disableRename={false} disableEdit={true} disableDelete={true} @@ -199,224 +182,67 @@ describe('Item Component', () => { />, ) - // Since buttons are hidden initially, we need to find the ActionButton - // In the actual implementation, they are rendered but hidden - const actionButtons = container.querySelectorAll('button') - const renameBtn = Array.from(actionButtons).find(btn => - btn.querySelector('.ri-edit-line') || btn.innerHTML.includes('RiEditLine'), - ) - - if (renameBtn) { - fireEvent.click(renameBtn) - // Should show input for rename - expect(screen.getByRole('textbox')).toBeInTheDocument() + const enterRenameMode = () => { + const firstButton = result.container.querySelectorAll('button')[0] as HTMLElement + fireEvent.click(firstButton) } + + return { ...result, onRename, enterRenameMode } + } + + it('should enter rename mode when rename button is clicked', () => { + const { enterRenameMode } = renderWithRenameEnabled() + + enterRenameMode() + + expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should show save and cancel buttons in rename mode', () => { - const onRename = vi.fn() - const credential = createCredential({ name: 'Original Name' }) + const { enterRenameMode } = renderWithRenameEnabled() - const { container } = render( - <Item - credential={credential} - onRename={onRename} - disableRename={false} - disableEdit={true} - disableDelete={true} - disableSetDefault={true} - />, - ) + enterRenameMode() - // Find and click rename button to enter rename mode - const actionButtons = container.querySelectorAll('button') - // Find the rename action button by looking for RiEditLine icon - actionButtons.forEach((btn) => { - if (btn.querySelector('svg')) { - fireEvent.click(btn) - } - }) - - // If we're in rename mode, there should be save/cancel buttons - const buttons = screen.queryAllByRole('button') - if (buttons.length >= 2) { - expect(screen.getByText('common.operation.save')).toBeInTheDocument() - expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() - } + expect(screen.getByText('common.operation.save')).toBeInTheDocument() + expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() }) it('should call onRename with new name when save is clicked', () => { - const onRename = vi.fn() - const credential = createCredential({ id: 'rename-test-id', name: 'Original' }) + const { enterRenameMode, onRename } = renderWithRenameEnabled({ id: 'rename-test-id' }) - const { container } = render( - <Item - credential={credential} - onRename={onRename} - disableRename={false} - disableEdit={true} - disableDelete={true} - disableSetDefault={true} - />, - ) + enterRenameMode() - // Trigger rename mode by clicking the rename button - const editIcon = container.querySelector('svg.ri-edit-line') - if (editIcon) { - fireEvent.click(editIcon.closest('button')!) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'New Name' } }) + fireEvent.click(screen.getByText('common.operation.save')) - // Now in rename mode, change input and save - const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'New Name' } }) - - // Click save - const saveButton = screen.getByText('common.operation.save') - fireEvent.click(saveButton) - - expect(onRename).toHaveBeenCalledWith({ - credential_id: 'rename-test-id', - name: 'New Name', - }) - } - }) - - it('should call onRename and exit rename mode when save button is clicked', () => { - const onRename = vi.fn() - const credential = createCredential({ id: 'rename-save-test', name: 'Original Name' }) - - const { container } = render( - <Item - credential={credential} - onRename={onRename} - disableRename={false} - disableEdit={true} - disableDelete={true} - disableSetDefault={true} - />, - ) - - // Find and click rename button to enter rename mode - // The button contains RiEditLine svg - const allButtons = Array.from(container.querySelectorAll('button')) - let renameButton: Element | null = null - for (const btn of allButtons) { - if (btn.querySelector('svg')) { - renameButton = btn - break - } - } - - if (renameButton) { - fireEvent.click(renameButton) - - // Should be in rename mode now - const input = screen.queryByRole('textbox') - if (input) { - expect(input).toHaveValue('Original Name') - - // Change the value - fireEvent.change(input, { target: { value: 'Updated Name' } }) - expect(input).toHaveValue('Updated Name') - - // Click save button - const saveButton = screen.getByText('common.operation.save') - fireEvent.click(saveButton) - - // Verify onRename was called with correct parameters - expect(onRename).toHaveBeenCalledTimes(1) - expect(onRename).toHaveBeenCalledWith({ - credential_id: 'rename-save-test', - name: 'Updated Name', - }) - - // Should exit rename mode - input should be gone - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() - } - } + expect(onRename).toHaveBeenCalledWith({ + credential_id: 'rename-test-id', + name: 'New Name', + }) + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() }) it('should exit rename mode when cancel is clicked', () => { - const credential = createCredential({ name: 'Original' }) + const { enterRenameMode } = renderWithRenameEnabled() - const { container } = render( - <Item - credential={credential} - disableRename={false} - disableEdit={true} - disableDelete={true} - disableSetDefault={true} - />, - ) + enterRenameMode() + expect(screen.getByRole('textbox')).toBeInTheDocument() - // Enter rename mode - const editIcon = container.querySelector('svg')?.closest('button') - if (editIcon) { - fireEvent.click(editIcon) + fireEvent.click(screen.getByText('common.operation.cancel')) - // If in rename mode, cancel button should exist - const cancelButton = screen.queryByText('common.operation.cancel') - if (cancelButton) { - fireEvent.click(cancelButton) - // Should exit rename mode - input should be gone - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() - } - } + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() }) - it('should update rename value when input changes', () => { - const credential = createCredential({ name: 'Original' }) + it('should update input value when typing', () => { + const { enterRenameMode } = renderWithRenameEnabled() - const { container } = render( - <Item - credential={credential} - disableRename={false} - disableEdit={true} - disableDelete={true} - disableSetDefault={true} - />, - ) + enterRenameMode() - // We need to get into rename mode first - // The rename button appears on hover in the actions area - const allButtons = container.querySelectorAll('button') - if (allButtons.length > 0) { - fireEvent.click(allButtons[0]) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'Updated Value' } }) - const input = screen.queryByRole('textbox') - if (input) { - fireEvent.change(input, { target: { value: 'Updated Value' } }) - expect(input).toHaveValue('Updated Value') - } - } - }) - - it('should stop propagation when clicking input in rename mode', () => { - const onItemClick = vi.fn() - const credential = createCredential() - - const { container } = render( - <Item - credential={credential} - onItemClick={onItemClick} - disableRename={false} - disableEdit={true} - disableDelete={true} - disableSetDefault={true} - />, - ) - - // Enter rename mode and click on input - const allButtons = container.querySelectorAll('button') - if (allButtons.length > 0) { - fireEvent.click(allButtons[0]) - - const input = screen.queryByRole('textbox') - if (input) { - fireEvent.click(input) - // onItemClick should not be called when clicking the input - expect(onItemClick).not.toHaveBeenCalled() - } - } + expect(input).toHaveValue('Updated Value') }) }) @@ -437,12 +263,9 @@ describe('Item Component', () => { />, ) - // Find set default button - const setDefaultButton = screen.queryByText('plugin.auth.setDefault') - if (setDefaultButton) { - fireEvent.click(setDefaultButton) - expect(onSetDefault).toHaveBeenCalledWith('test-credential-id') - } + const setDefaultButton = screen.getByText('plugin.auth.setDefault') + fireEvent.click(setDefaultButton) + expect(onSetDefault).toHaveBeenCalledWith('test-credential-id') }) it('should not show set default button when credential is already default', () => { @@ -517,16 +340,13 @@ describe('Item Component', () => { />, ) - // Find the edit button (RiEqualizer2Line icon) - const editButton = container.querySelector('svg')?.closest('button') - if (editButton) { - fireEvent.click(editButton) - expect(onEdit).toHaveBeenCalledWith('edit-test-id', { - api_key: 'secret', - __name__: 'Edit Test', - __credential_id__: 'edit-test-id', - }) - } + const editButton = container.querySelector('svg')?.closest('button') as HTMLElement + fireEvent.click(editButton) + expect(onEdit).toHaveBeenCalledWith('edit-test-id', { + api_key: 'secret', + __name__: 'Edit Test', + __credential_id__: 'edit-test-id', + }) }) it('should not show edit button for OAuth credentials', () => { @@ -584,12 +404,9 @@ describe('Item Component', () => { />, ) - // Find delete button (RiDeleteBinLine icon) - const deleteButton = container.querySelector('svg')?.closest('button') - if (deleteButton) { - fireEvent.click(deleteButton) - expect(onDelete).toHaveBeenCalledWith('delete-test-id') - } + const deleteButton = container.querySelector('svg')?.closest('button') as HTMLElement + fireEvent.click(deleteButton) + expect(onDelete).toHaveBeenCalledWith('delete-test-id') }) it('should not show delete button when disableDelete is true', () => { @@ -704,44 +521,15 @@ describe('Item Component', () => { />, ) - // Find delete button and click - const deleteButton = container.querySelector('svg')?.closest('button') - if (deleteButton) { - fireEvent.click(deleteButton) - // onDelete should be called but not onItemClick (due to stopPropagation) - expect(onDelete).toHaveBeenCalled() - // Note: onItemClick might still be called due to event bubbling in test environment - } - }) - - it('should disable action buttons when disabled prop is true', () => { - const onSetDefault = vi.fn() - const credential = createCredential({ is_default: false }) - - render( - <Item - credential={credential} - onSetDefault={onSetDefault} - disabled={true} - disableSetDefault={false} - disableRename={true} - disableEdit={true} - disableDelete={true} - />, - ) - - // Set default button should be disabled - const setDefaultButton = screen.queryByText('plugin.auth.setDefault') - if (setDefaultButton) { - const button = setDefaultButton.closest('button') - expect(button).toBeDisabled() - } + const deleteButton = container.querySelector('svg')?.closest('button') as HTMLElement + fireEvent.click(deleteButton) + expect(onDelete).toHaveBeenCalled() }) }) // ==================== showAction Logic Tests ==================== describe('Show Action Logic', () => { - it('should not show action area when all actions are disabled', () => { + it('should not render action buttons when all actions are disabled', () => { const credential = createCredential() const { container } = render( @@ -754,12 +542,10 @@ describe('Item Component', () => { />, ) - // Should not have action area with hover:flex - const actionArea = container.querySelector('.group-hover\\:flex') - expect(actionArea).not.toBeInTheDocument() + expect(container.querySelectorAll('button').length).toBe(0) }) - it('should show action area when at least one action is enabled', () => { + it('should render action buttons when at least one action is enabled', () => { const credential = createCredential() const { container } = render( @@ -772,38 +558,33 @@ describe('Item Component', () => { />, ) - // Should have action area - const actionArea = container.querySelector('.group-hover\\:flex') - expect(actionArea).toBeInTheDocument() + expect(container.querySelectorAll('button').length).toBeGreaterThan(0) }) }) - // ==================== Edge Cases ==================== describe('Edge Cases', () => { it('should handle credential with empty name', () => { const credential = createCredential({ name: '' }) - render(<Item credential={credential} />) - - // Should render without crashing - expect(document.querySelector('.group')).toBeInTheDocument() + expect(() => { + render(<Item credential={credential} />) + }).not.toThrow() }) it('should handle credential with undefined credentials object', () => { const credential = createCredential({ credentials: undefined }) - render( - <Item - credential={credential} - disableEdit={false} - disableRename={true} - disableDelete={true} - disableSetDefault={true} - />, - ) - - // Should render without crashing - expect(document.querySelector('.group')).toBeInTheDocument() + expect(() => { + render( + <Item + credential={credential} + disableEdit={false} + disableRename={true} + disableDelete={true} + disableSetDefault={true} + />, + ) + }).not.toThrow() }) it('should handle all optional callbacks being undefined', () => { @@ -814,13 +595,13 @@ describe('Item Component', () => { }).not.toThrow() }) - it('should properly display long credential names with truncation', () => { + it('should display long credential names with title attribute', () => { const longName = 'A'.repeat(100) const credential = createCredential({ name: longName }) const { container } = render(<Item credential={credential} />) - const nameElement = container.querySelector('.truncate') + const nameElement = container.querySelector('[title]') expect(nameElement).toBeInTheDocument() expect(nameElement?.getAttribute('title')).toBe(longName) }) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx index b6710887a5..480f399c91 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx @@ -4,10 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import Toast from '@/app/components/base/toast' import EndpointCard from '../endpoint-card' -vi.mock('copy-to-clipboard', () => ({ - default: vi.fn(), -})) - const mockHandleChange = vi.fn() const mockEnableEndpoint = vi.fn() const mockDisableEndpoint = vi.fn() @@ -133,6 +129,10 @@ describe('EndpointCard', () => { failureFlags.update = false // Mock Toast.notify to prevent toast elements from accumulating in DOM vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + // Polyfill document.execCommand for copy-to-clipboard in jsdom + if (typeof document.execCommand !== 'function') { + document.execCommand = vi.fn().mockReturnValue(true) + } }) afterEach(() => { @@ -192,12 +192,8 @@ describe('EndpointCard', () => { it('should show delete confirm when delete clicked', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) - // Find delete button by its destructive class const allButtons = screen.getAllByRole('button') - const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary')) - expect(deleteButton).toBeDefined() - if (deleteButton) - fireEvent.click(deleteButton) + fireEvent.click(allButtons[1]) expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument() }) @@ -206,10 +202,7 @@ describe('EndpointCard', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) const allButtons = screen.getAllByRole('button') - const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary')) - expect(deleteButton).toBeDefined() - if (deleteButton) - fireEvent.click(deleteButton) + fireEvent.click(allButtons[1]) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1') @@ -218,10 +211,8 @@ describe('EndpointCard', () => { it('should show edit modal when edit clicked', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) - const actionButtons = screen.getAllByRole('button', { name: '' }) - const editButton = actionButtons[0] - if (editButton) - fireEvent.click(editButton) + const allButtons = screen.getAllByRole('button') + fireEvent.click(allButtons[0]) expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument() }) @@ -229,10 +220,8 @@ describe('EndpointCard', () => { it('should call updateEndpoint when save in modal', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) - const actionButtons = screen.getAllByRole('button', { name: '' }) - const editButton = actionButtons[0] - if (editButton) - fireEvent.click(editButton) + const allButtons = screen.getAllByRole('button') + fireEvent.click(allButtons[0]) fireEvent.click(screen.getByTestId('modal-save')) expect(mockUpdateEndpoint).toHaveBeenCalled() @@ -243,20 +232,14 @@ describe('EndpointCard', () => { it('should reset copy state after timeout', async () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) - // Find copy button by its class const allButtons = screen.getAllByRole('button') - const copyButton = allButtons.find(btn => btn.classList.contains('ml-2')) - expect(copyButton).toBeDefined() - if (copyButton) { - fireEvent.click(copyButton) + fireEvent.click(allButtons[2]) - act(() => { - vi.advanceTimersByTime(2000) - }) + act(() => { + vi.advanceTimersByTime(2000) + }) - // After timeout, the component should still be rendered correctly - expect(screen.getByText('Test Endpoint')).toBeInTheDocument() - } + expect(screen.getByText('Test Endpoint')).toBeInTheDocument() }) }) @@ -296,10 +279,7 @@ describe('EndpointCard', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) const allButtons = screen.getAllByRole('button') - const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary')) - expect(deleteButton).toBeDefined() - if (deleteButton) - fireEvent.click(deleteButton) + fireEvent.click(allButtons[1]) expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) @@ -310,10 +290,8 @@ describe('EndpointCard', () => { it('should hide edit modal when cancel clicked', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) - const actionButtons = screen.getAllByRole('button', { name: '' }) - const editButton = actionButtons[0] - if (editButton) - fireEvent.click(editButton) + const allButtons = screen.getAllByRole('button') + fireEvent.click(allButtons[0]) expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument() fireEvent.click(screen.getByTestId('modal-cancel')) @@ -348,9 +326,7 @@ describe('EndpointCard', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) const allButtons = screen.getAllByRole('button') - const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary')) - if (deleteButton) - fireEvent.click(deleteButton) + fireEvent.click(allButtons[1]) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockDeleteEndpoint).toHaveBeenCalled() @@ -359,21 +335,15 @@ describe('EndpointCard', () => { it('should show error toast when update fails', () => { render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />) - const actionButtons = screen.getAllByRole('button', { name: '' }) - const editButton = actionButtons[0] - expect(editButton).toBeDefined() - if (editButton) - fireEvent.click(editButton) + const allButtons = screen.getAllByRole('button') + fireEvent.click(allButtons[0]) - // Verify modal is open expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument() - // Set failure flag before save is clicked failureFlags.update = true fireEvent.click(screen.getByTestId('modal-save')) expect(mockUpdateEndpoint).toHaveBeenCalled() - // On error, handleChange is not called expect(mockHandleChange).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx index bc25cd816f..8f26aa6c5a 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx @@ -112,8 +112,7 @@ describe('EndpointList', () => { it('should render add button', () => { render(<EndpointList detail={createPluginDetail()} />) - const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) - expect(addButton).toBeDefined() + expect(screen.getAllByRole('button').length).toBeGreaterThan(0) }) }) @@ -121,9 +120,8 @@ describe('EndpointList', () => { it('should show modal when add button clicked', () => { render(<EndpointList detail={createPluginDetail()} />) - const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) - if (addButton) - fireEvent.click(addButton) + const addButton = screen.getAllByRole('button')[0] + fireEvent.click(addButton) expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument() }) @@ -131,9 +129,8 @@ describe('EndpointList', () => { it('should hide modal when cancel clicked', () => { render(<EndpointList detail={createPluginDetail()} />) - const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) - if (addButton) - fireEvent.click(addButton) + const addButton = screen.getAllByRole('button')[0] + fireEvent.click(addButton) expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument() fireEvent.click(screen.getByTestId('modal-cancel')) @@ -143,9 +140,8 @@ describe('EndpointList', () => { it('should call createEndpoint when save clicked', () => { render(<EndpointList detail={createPluginDetail()} />) - const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) - if (addButton) - fireEvent.click(addButton) + const addButton = screen.getAllByRole('button')[0] + fireEvent.click(addButton) fireEvent.click(screen.getByTestId('modal-save')) expect(mockCreateEndpoint).toHaveBeenCalled() @@ -158,7 +154,6 @@ describe('EndpointList', () => { detail.declaration.tool = {} as PluginDetail['declaration']['tool'] render(<EndpointList detail={detail} />) - // Verify the component renders correctly expect(screen.getByText('plugin.detailPanel.endpoints')).toBeInTheDocument() }) }) @@ -177,23 +172,12 @@ describe('EndpointList', () => { }) }) - describe('Tooltip', () => { - it('should render with tooltip content', () => { - render(<EndpointList detail={createPluginDetail()} />) - - // Tooltip is rendered - the add button should be visible - const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) - expect(addButton).toBeDefined() - }) - }) - describe('Create Endpoint Flow', () => { it('should invalidate endpoint list after successful create', () => { render(<EndpointList detail={createPluginDetail()} />) - const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) - if (addButton) - fireEvent.click(addButton) + const addButton = screen.getAllByRole('button')[0] + fireEvent.click(addButton) fireEvent.click(screen.getByTestId('modal-save')) expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin') @@ -202,9 +186,8 @@ describe('EndpointList', () => { it('should pass correct params to createEndpoint', () => { render(<EndpointList detail={createPluginDetail()} />) - const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) - if (addButton) - fireEvent.click(addButton) + const addButton = screen.getAllByRole('button')[0] + fireEvent.click(addButton) fireEvent.click(screen.getByTestId('modal-save')) expect(mockCreateEndpoint).toHaveBeenCalledWith({ diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx index 4ed7ec48a5..1dfe31c6b1 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx @@ -158,11 +158,8 @@ describe('EndpointModal', () => { />, ) - // Find the close button (ActionButton with RiCloseLine icon) const allButtons = screen.getAllByRole('button') - const closeButton = allButtons.find(btn => btn.classList.contains('action-btn')) - if (closeButton) - fireEvent.click(closeButton) + fireEvent.click(allButtons[0]) expect(mockOnCancel).toHaveBeenCalledTimes(1) }) @@ -318,7 +315,16 @@ describe('EndpointModal', () => { }) describe('Boolean Field Processing', () => { - it('should convert string "true" to boolean true', () => { + it.each([ + { input: 'true', expected: true }, + { input: '1', expected: true }, + { input: 'True', expected: true }, + { input: 'false', expected: false }, + { input: 1, expected: true }, + { input: 0, expected: false }, + { input: true, expected: true }, + { input: false, expected: false }, + ])('should convert $input to $expected for boolean fields', ({ input, expected }) => { const schemasWithBoolean = [ { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, ] as unknown as FormSchema[] @@ -326,7 +332,7 @@ describe('EndpointModal', () => { render( <EndpointModal formSchemas={schemasWithBoolean} - defaultValues={{ enabled: 'true' }} + defaultValues={{ enabled: input }} onCancel={mockOnCancel} onSaved={mockOnSaved} pluginDetail={mockPluginDetail} @@ -335,147 +341,7 @@ describe('EndpointModal', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) - }) - - it('should convert string "1" to boolean true', () => { - const schemasWithBoolean = [ - { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, - ] as unknown as FormSchema[] - - render( - <EndpointModal - formSchemas={schemasWithBoolean} - defaultValues={{ enabled: '1' }} - onCancel={mockOnCancel} - onSaved={mockOnSaved} - pluginDetail={mockPluginDetail} - />, - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) - }) - - it('should convert string "True" to boolean true', () => { - const schemasWithBoolean = [ - { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, - ] as unknown as FormSchema[] - - render( - <EndpointModal - formSchemas={schemasWithBoolean} - defaultValues={{ enabled: 'True' }} - onCancel={mockOnCancel} - onSaved={mockOnSaved} - pluginDetail={mockPluginDetail} - />, - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) - }) - - it('should convert string "false" to boolean false', () => { - const schemasWithBoolean = [ - { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, - ] as unknown as FormSchema[] - - render( - <EndpointModal - formSchemas={schemasWithBoolean} - defaultValues={{ enabled: 'false' }} - onCancel={mockOnCancel} - onSaved={mockOnSaved} - pluginDetail={mockPluginDetail} - />, - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) - }) - - it('should convert number 1 to boolean true', () => { - const schemasWithBoolean = [ - { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, - ] as unknown as FormSchema[] - - render( - <EndpointModal - formSchemas={schemasWithBoolean} - defaultValues={{ enabled: 1 }} - onCancel={mockOnCancel} - onSaved={mockOnSaved} - pluginDetail={mockPluginDetail} - />, - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) - }) - - it('should convert number 0 to boolean false', () => { - const schemasWithBoolean = [ - { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, - ] as unknown as FormSchema[] - - render( - <EndpointModal - formSchemas={schemasWithBoolean} - defaultValues={{ enabled: 0 }} - onCancel={mockOnCancel} - onSaved={mockOnSaved} - pluginDetail={mockPluginDetail} - />, - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) - }) - - it('should preserve boolean true value', () => { - const schemasWithBoolean = [ - { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, - ] as unknown as FormSchema[] - - render( - <EndpointModal - formSchemas={schemasWithBoolean} - defaultValues={{ enabled: true }} - onCancel={mockOnCancel} - onSaved={mockOnSaved} - pluginDetail={mockPluginDetail} - />, - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) - }) - - it('should preserve boolean false value', () => { - const schemasWithBoolean = [ - { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, - ] as unknown as FormSchema[] - - render( - <EndpointModal - formSchemas={schemasWithBoolean} - defaultValues={{ enabled: false }} - onCancel={mockOnCancel} - onSaved={mockOnSaved} - pluginDetail={mockPluginDetail} - />, - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) + expect(mockOnSaved).toHaveBeenCalledWith({ enabled: expected }) }) it('should not process non-boolean fields', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx index 837a679b4b..5c7ebfc57a 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx @@ -136,18 +136,27 @@ describe('SubscriptionList', () => { expect(screen.getByText('Subscription One')).toBeInTheDocument() }) - it('should highlight the selected subscription when selectedId is provided', () => { - render( + it('should visually distinguish selected subscription from unselected', () => { + const { rerender } = render( <SubscriptionList mode={SubscriptionListMode.SELECTOR} selectedId="sub-1" />, ) - const selectedButton = screen.getByRole('button', { name: 'Subscription One' }) - const selectedRow = selectedButton.closest('div') + const getRowClassName = () => + screen.getByRole('button', { name: 'Subscription One' }).closest('div')?.className ?? '' - expect(selectedRow).toHaveClass('bg-state-base-hover') + const selectedClassName = getRowClassName() + + rerender( + <SubscriptionList + mode={SubscriptionListMode.SELECTOR} + selectedId="other-id" + />, + ) + + expect(selectedClassName).not.toBe(getRowClassName()) }) }) @@ -190,11 +199,9 @@ describe('SubscriptionList', () => { />, ) - const deleteButton = container.querySelector('.subscription-delete-btn') + const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement expect(deleteButton).toBeTruthy() - - if (deleteButton) - fireEvent.click(deleteButton) + fireEvent.click(deleteButton) expect(onSelect).not.toHaveBeenCalled() expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx index b131def3c7..c6fb42faab 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx @@ -1,17 +1,12 @@ import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types' -import { fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import Toast from '@/app/components/base/toast' import LogViewer from '../log-viewer' const mockToastNotify = vi.fn() const mockWriteText = vi.fn() -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: (args: { type: string, message: string }) => mockToastNotify(args), - }, -})) - vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ default: ({ value }: { value: unknown }) => ( <div data-testid="code-editor">{JSON.stringify(value)}</div> @@ -62,6 +57,10 @@ beforeEach(() => { }, configurable: true, }) + vi.spyOn(Toast, 'notify').mockImplementation((args) => { + mockToastNotify(args) + return { clear: vi.fn() } + }) }) describe('LogViewer', () => { @@ -99,13 +98,20 @@ describe('LogViewer', () => { expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument() }) - it('should render error styling when response is an error', () => { - render(<LogViewer logs={[createLog({ response: { ...createLog().response, status_code: 500 } })]} />) + it('should apply distinct styling when response is an error', () => { + const { container: errorContainer } = render( + <LogViewer logs={[createLog({ response: { ...createLog().response, status_code: 500 } })]} />, + ) + const errorWrapperClass = errorContainer.querySelector('[class*="border"]')?.className ?? '' - const trigger = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }) - const wrapper = trigger.parentElement as HTMLElement + cleanup() - expect(wrapper).toHaveClass('border-state-destructive-border') + const { container: okContainer } = render( + <LogViewer logs={[createLog()]} />, + ) + const okWrapperClass = okContainer.querySelector('[class*="border"]')?.className ?? '' + + expect(errorWrapperClass).not.toBe(okWrapperClass) }) it('should render raw response text and allow copying', () => { @@ -121,10 +127,9 @@ describe('LogViewer', () => { expect(screen.getByText('plain response')).toBeInTheDocument() - const copyButton = screen.getAllByRole('button').find(button => button !== toggleButton) - expect(copyButton).toBeDefined() - if (copyButton) - fireEvent.click(copyButton) + const copyButton = screen.getAllByRole('button').find(button => button !== toggleButton) as HTMLElement + expect(copyButton).toBeTruthy() + fireEvent.click(copyButton) expect(mockWriteText).toHaveBeenCalledWith('plain response') expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx index 48fe2e52c4..83d0cdd89d 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx @@ -1,6 +1,7 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import Toast from '@/app/components/base/toast' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' import { SubscriptionSelectorView } from '../selector-view' @@ -25,12 +26,6 @@ vi.mock('@/service/use-triggers', () => ({ useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }), })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: vi.fn(), - }, -})) - const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({ id: 'sub-1', name: 'Subscription One', @@ -47,6 +42,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg beforeEach(() => { vi.clearAllMocks() mockSubscriptions = [createSubscription()] + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) }) describe('SubscriptionSelectorView', () => { @@ -75,18 +71,19 @@ describe('SubscriptionSelectorView', () => { }).not.toThrow() }) - it('should highlight selected subscription row when selectedId matches', () => { - render(<SubscriptionSelectorView selectedId="sub-1" />) + it('should distinguish selected vs unselected subscription row', () => { + const { rerender } = render(<SubscriptionSelectorView selectedId="sub-1" />) - const selectedRow = screen.getByRole('button', { name: 'Subscription One' }).closest('div') - expect(selectedRow).toHaveClass('bg-state-base-hover') - }) + const getRowClassName = () => + screen.getByRole('button', { name: 'Subscription One' }).closest('div')?.className ?? '' - it('should not highlight row when selectedId does not match', () => { - render(<SubscriptionSelectorView selectedId="other-id" />) + const selectedClassName = getRowClassName() - const row = screen.getByRole('button', { name: 'Subscription One' }).closest('div') - expect(row).not.toHaveClass('bg-state-base-hover') + rerender(<SubscriptionSelectorView selectedId="other-id" />) + + const unselectedClassName = getRowClassName() + + expect(selectedClassName).not.toBe(unselectedClassName) }) it('should omit header when there are no subscriptions', () => { @@ -100,11 +97,9 @@ describe('SubscriptionSelectorView', () => { it('should show delete confirm when delete action is clicked', () => { const { container } = render(<SubscriptionSelectorView />) - const deleteButton = container.querySelector('.subscription-delete-btn') + const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement expect(deleteButton).toBeTruthy() - - if (deleteButton) - fireEvent.click(deleteButton) + fireEvent.click(deleteButton) expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument() }) @@ -113,9 +108,8 @@ describe('SubscriptionSelectorView', () => { const onSelect = vi.fn() const { container } = render(<SubscriptionSelectorView onSelect={onSelect} />) - const deleteButton = container.querySelector('.subscription-delete-btn') - if (deleteButton) - fireEvent.click(deleteButton) + const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement + fireEvent.click(deleteButton) fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) @@ -127,9 +121,8 @@ describe('SubscriptionSelectorView', () => { const onSelect = vi.fn() const { container } = render(<SubscriptionSelectorView onSelect={onSelect} />) - const deleteButton = container.querySelector('.subscription-delete-btn') - if (deleteButton) - fireEvent.click(deleteButton) + const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement + fireEvent.click(deleteButton) fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/ })) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx index cafd8178cf..a51bc2954f 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx @@ -1,6 +1,7 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import Toast from '@/app/components/base/toast' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' import SubscriptionCard from '../subscription-card' @@ -29,12 +30,6 @@ vi.mock('@/service/use-triggers', () => ({ useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }), })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: vi.fn(), - }, -})) - const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({ id: 'sub-1', name: 'Subscription One', @@ -50,6 +45,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg beforeEach(() => { vi.clearAllMocks() + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) }) describe('SubscriptionCard', () => { @@ -69,11 +65,9 @@ describe('SubscriptionCard', () => { it('should open delete confirmation when delete action is clicked', () => { const { container } = render(<SubscriptionCard data={createSubscription()} />) - const deleteButton = container.querySelector('.subscription-delete-btn') + const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement expect(deleteButton).toBeTruthy() - - if (deleteButton) - fireEvent.click(deleteButton) + fireEvent.click(deleteButton) expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument() }) @@ -81,9 +75,7 @@ describe('SubscriptionCard', () => { it('should open edit modal when edit action is clicked', () => { const { container } = render(<SubscriptionCard data={createSubscription()} />) - const actionButtons = container.querySelectorAll('button') - const editButton = actionButtons[0] - + const editButton = container.querySelectorAll('button')[0] fireEvent.click(editButton) expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument() diff --git a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index 5c3781e8c1..99318b07b3 100644 --- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -1,4 +1,3 @@ -import type { PropsWithChildren } from 'react' import type { EnvironmentVariable } from '@/app/components/workflow/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { createMockProviderContextValue } from '@/__mocks__/provider-context' @@ -16,23 +15,6 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }), })) -vi.mock('next/image', () => ({ - default: ({ src, alt, width, height }: { src: string, alt: string, width: number, height: number }) => ( - // eslint-disable-next-line next/no-img-element - <img src={src} alt={alt} width={width} height={height} data-testid="mock-image" /> - ), -})) - -vi.mock('next/dynamic', () => ({ - default: (importFn: () => Promise<{ default: React.ComponentType<unknown> }>, options?: { ssr?: boolean }) => { - const DynamicComponent = ({ children, ...props }: PropsWithChildren) => { - return <div data-testid="dynamic-component" data-ssr={options?.ssr ?? true} {...props}>{children}</div> - } - DynamicComponent.displayName = 'DynamicComponent' - return DynamicComponent - }, -})) - let mockShowImportDSLModal = false const mockSetShowImportDSLModal = vi.fn((value: boolean) => { mockShowImportDSLModal = value @@ -247,18 +229,6 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: vi.fn(), - }, - useToastContext: () => ({ - notify: vi.fn(), - }), - ToastContext: { - Provider: ({ children }: PropsWithChildren) => children, - }, -})) - vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light', @@ -276,7 +246,7 @@ vi.mock('@/context/provider-context', () => ({ })) vi.mock('@/app/components/workflow', () => ({ - WorkflowWithInnerContext: ({ children }: PropsWithChildren) => ( + WorkflowWithInnerContext: ({ children }: { children: React.ReactNode }) => ( <div data-testid="workflow-inner-context">{children}</div> ), })) @@ -300,16 +270,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ }), })) -vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ - default: ({ envList, onConfirm, onClose }: { envList: EnvironmentVariable[], onConfirm: () => void, onClose: () => void }) => ( - <div data-testid="dsl-export-confirm-modal"> - <span data-testid="env-count">{envList.length}</span> - <button data-testid="export-confirm" onClick={onConfirm}>Confirm</button> - <button data-testid="export-close" onClick={onClose}>Close</button> - </div> - ), -})) - vi.mock('@/app/components/workflow/constants', () => ({ DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', @@ -322,125 +282,6 @@ vi.mock('@/app/components/workflow/utils', () => ({ getKeyboardKeyNameBySystem: (key: string) => key, })) -vi.mock('@/app/components/base/confirm', () => ({ - default: ({ title, content, isShow, onConfirm, onCancel, isLoading, isDisabled }: { - title: string - content: string - isShow: boolean - onConfirm: () => void - onCancel: () => void - isLoading?: boolean - isDisabled?: boolean - }) => isShow - ? ( - <div data-testid="confirm-modal"> - <div data-testid="confirm-title">{title}</div> - <div data-testid="confirm-content">{content}</div> - <button - data-testid="confirm-btn" - onClick={onConfirm} - disabled={isDisabled || isLoading} - > - Confirm - </button> - <button data-testid="cancel-btn" onClick={onCancel}>Cancel</button> - </div> - ) - : null, -})) - -vi.mock('@/app/components/base/modal', () => ({ - default: ({ children, isShow, onClose, className }: PropsWithChildren<{ - isShow: boolean - onClose: () => void - className?: string - }>) => isShow - ? ( - <div data-testid="modal" className={className} onClick={e => e.target === e.currentTarget && onClose()}> - {children} - </div> - ) - : null, -})) - -vi.mock('@/app/components/base/input', () => ({ - default: ({ value, onChange, placeholder }: { - value: string - onChange: (e: React.ChangeEvent<HTMLInputElement>) => void - placeholder?: string - }) => ( - <input - data-testid="input" - value={value} - onChange={onChange} - placeholder={placeholder} - /> - ), -})) - -vi.mock('@/app/components/base/textarea', () => ({ - default: ({ value, onChange, placeholder, className }: { - value: string - onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void - placeholder?: string - className?: string - }) => ( - <textarea - data-testid="textarea" - value={value} - onChange={onChange} - placeholder={placeholder} - className={className} - /> - ), -})) - -vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ onClick, iconType, icon, background, imageUrl, className, size }: { - onClick?: () => void - iconType?: string - icon?: string - background?: string - imageUrl?: string - className?: string - size?: string - }) => ( - <div - data-testid="app-icon" - data-icon-type={iconType} - data-icon={icon} - data-background={background} - data-image-url={imageUrl} - data-size={size} - className={className} - onClick={onClick} - /> - ), -})) - -vi.mock('@/app/components/base/app-icon-picker', () => ({ - default: ({ onSelect, onClose }: { - onSelect: (item: { type: string, icon?: string, background?: string, url?: string }) => void - onClose: () => void - }) => ( - <div data-testid="app-icon-picker"> - <button - data-testid="select-emoji" - onClick={() => onSelect({ type: 'emoji', icon: '🚀', background: '#000000' })} - > - Select Emoji - </button> - <button - data-testid="select-image" - onClick={() => onSelect({ type: 'image', url: 'https://example.com/icon.png' })} - > - Select Image - </button> - <button data-testid="close-picker" onClick={onClose}>Close</button> - </div> - ), -})) - vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ default: ({ file, updateFile, className, accept, displayName }: { file?: File @@ -466,12 +307,6 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ ), })) -vi.mock('use-context-selector', () => ({ - useContext: vi.fn(() => ({ - notify: vi.fn(), - })), -})) - vi.mock('../rag-pipeline-header', () => ({ default: () => <div data-testid="rag-pipeline-header" />, })) @@ -512,6 +347,28 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ ), })) +// Silence expected console.error from Dialog/Modal rendering +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +// Helper to find the name input in PublishAsKnowledgePipelineModal +function getNameInput() { + return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.namePlaceholder') +} + +// Helper to find the description textarea in PublishAsKnowledgePipelineModal +function getDescriptionTextarea() { + return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.descriptionPlaceholder') +} + +// Helper to find the AppIcon span in PublishAsKnowledgePipelineModal +// HeadlessUI Dialog renders via portal to document.body, so we search the full document +function getAppIcon() { + const emoji = document.querySelector('em-emoji') + return emoji?.closest('span') as HTMLElement +} + describe('Conversion', () => { beforeEach(() => { vi.clearAllMocks() @@ -546,7 +403,8 @@ describe('Conversion', () => { it('should render PipelineScreenShot component', () => { render(<Conversion />) - expect(screen.getByTestId('mock-image')).toBeInTheDocument() + // PipelineScreenShot renders a <picture> element with <source> children + expect(document.querySelector('picture')).toBeInTheDocument() }) }) @@ -557,8 +415,9 @@ describe('Conversion', () => { const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) fireEvent.click(convertButton) - expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() - expect(screen.getByTestId('confirm-title')).toHaveTextContent('datasetPipeline.conversion.confirm.title') + // Real Confirm renders title and content via portal + expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.conversion.confirm.content')).toBeInTheDocument() }) it('should hide confirm modal when cancel is clicked', () => { @@ -566,10 +425,11 @@ describe('Conversion', () => { const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) fireEvent.click(convertButton) - expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('cancel-btn')) - expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + // Real Confirm renders cancel button with i18n text + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument() }) }) @@ -588,7 +448,7 @@ describe('Conversion', () => { const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) fireEvent.click(convertButton) - fireEvent.click(screen.getByTestId('confirm-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { expect(mockConvertFn).toHaveBeenCalledWith('test-dataset-id', expect.objectContaining({ @@ -607,12 +467,12 @@ describe('Conversion', () => { const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) fireEvent.click(convertButton) - expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('confirm-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { - expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument() }) }) @@ -625,12 +485,13 @@ describe('Conversion', () => { const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) fireEvent.click(convertButton) - fireEvent.click(screen.getByTestId('confirm-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { expect(mockConvertFn).toHaveBeenCalled() }) - expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + // Confirm modal stays open on failure + expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument() }) it('should show error toast when conversion throws error', async () => { @@ -642,7 +503,7 @@ describe('Conversion', () => { const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) fireEvent.click(convertButton) - fireEvent.click(screen.getByTestId('confirm-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { expect(mockConvertFn).toHaveBeenCalled() @@ -681,23 +542,24 @@ describe('PipelineScreenShot', () => { it('should render without crashing', () => { render(<PipelineScreenShot />) - expect(screen.getByTestId('mock-image')).toBeInTheDocument() + expect(document.querySelector('picture')).toBeInTheDocument() }) - it('should render with correct image attributes', () => { + it('should render source elements for different resolutions', () => { render(<PipelineScreenShot />) - const img = screen.getByTestId('mock-image') - expect(img).toHaveAttribute('alt', 'Pipeline Screenshot') - expect(img).toHaveAttribute('width', '692') - expect(img).toHaveAttribute('height', '456') + const sources = document.querySelectorAll('source') + expect(sources).toHaveLength(3) + expect(sources[0]).toHaveAttribute('media', '(resolution: 1x)') + expect(sources[1]).toHaveAttribute('media', '(resolution: 2x)') + expect(sources[2]).toHaveAttribute('media', '(resolution: 3x)') }) it('should use correct theme-based source path', () => { render(<PipelineScreenShot />) - const img = screen.getByTestId('mock-image') - expect(img).toHaveAttribute('src', '/public/screenshots/light/Pipeline.png') + const source = document.querySelector('source') + expect(source).toHaveAttribute('srcSet', '/public/screenshots/light/Pipeline.png') }) }) @@ -752,20 +614,22 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should render name input with default value from store', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - const input = screen.getByTestId('input') + const input = getNameInput() expect(input).toHaveValue('Test Knowledge') }) it('should render description textarea', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - expect(screen.getByTestId('textarea')).toBeInTheDocument() + expect(getDescriptionTextarea()).toBeInTheDocument() }) it('should render app icon', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - expect(screen.getByTestId('app-icon')).toBeInTheDocument() + // Real AppIcon renders an em-emoji custom element inside a span + // HeadlessUI Dialog renders via portal, so search the full document + expect(document.querySelector('em-emoji')).toBeInTheDocument() }) it('should render cancel and confirm buttons', () => { @@ -780,7 +644,7 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should update name when input changes', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - const input = screen.getByTestId('input') + const input = getNameInput() fireEvent.change(input, { target: { value: 'New Pipeline Name' } }) expect(input).toHaveValue('New Pipeline Name') @@ -789,7 +653,7 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should update description when textarea changes', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - const textarea = screen.getByTestId('textarea') + const textarea = getDescriptionTextarea() fireEvent.change(textarea, { target: { value: 'New description' } }) expect(textarea).toHaveValue('New description') @@ -816,8 +680,8 @@ describe('PublishAsKnowledgePipelineModal', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - fireEvent.change(screen.getByTestId('input'), { target: { value: ' Trimmed Name ' } }) - fireEvent.change(screen.getByTestId('textarea'), { target: { value: ' Trimmed Description ' } }) + fireEvent.change(getNameInput(), { target: { value: ' Trimmed Name ' } }) + fireEvent.change(getDescriptionTextarea(), { target: { value: ' Trimmed Description ' } }) fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i })) @@ -831,40 +695,57 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should show app icon picker when icon is clicked', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - fireEvent.click(screen.getByTestId('app-icon')) + const appIcon = getAppIcon() + fireEvent.click(appIcon) - expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument() + // Real AppIconPicker renders with Cancel and OK buttons + expect(screen.getByRole('button', { name: /iconPicker\.cancel/ })).toBeInTheDocument() }) - it('should update icon when emoji is selected', () => { + it('should update icon when emoji is selected', async () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - fireEvent.click(screen.getByTestId('app-icon')) + const appIcon = getAppIcon() + fireEvent.click(appIcon) - fireEvent.click(screen.getByTestId('select-emoji')) + // Click the first emoji in the grid (search full document since Dialog uses portal) + const gridEmojis = document.querySelectorAll('.grid em-emoji') + expect(gridEmojis.length).toBeGreaterThan(0) + fireEvent.click(gridEmojis[0].parentElement!.parentElement!) - expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() + // Click OK to confirm selection + fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) + + // Picker should close + await waitFor(() => { + expect(screen.queryByRole('button', { name: /iconPicker\.cancel/ })).not.toBeInTheDocument() + }) }) - it('should update icon when image is selected', () => { + it('should switch to image tab in icon picker', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - fireEvent.click(screen.getByTestId('app-icon')) + const appIcon = getAppIcon() + fireEvent.click(appIcon) - fireEvent.click(screen.getByTestId('select-image')) + // Switch to image tab + const imageTab = screen.getByRole('button', { name: /iconPicker\.image/ }) + fireEvent.click(imageTab) - expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() + // Picker should still be open + expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument() }) - it('should close picker and restore icon when picker is closed', () => { + it('should close picker when cancel is clicked', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - fireEvent.click(screen.getByTestId('app-icon')) - expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument() + const appIcon = getAppIcon() + fireEvent.click(appIcon) + expect(screen.getByRole('button', { name: /iconPicker\.cancel/ })).toBeInTheDocument() - fireEvent.click(screen.getByTestId('close-picker')) + fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ })) - expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /iconPicker\.ok/ })).not.toBeInTheDocument() }) }) @@ -872,7 +753,7 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should disable publish button when name is empty', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - fireEvent.change(screen.getByTestId('input'), { target: { value: '' } }) + fireEvent.change(getNameInput(), { target: { value: '' } }) const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i }) expect(publishButton).toBeDisabled() @@ -881,7 +762,7 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should disable publish button when name is only whitespace', () => { render(<PublishAsKnowledgePipelineModal {...defaultProps} />) - fireEvent.change(screen.getByTestId('input'), { target: { value: ' ' } }) + fireEvent.change(getNameInput(), { target: { value: ' ' } }) const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i }) expect(publishButton).toBeDisabled() @@ -908,7 +789,8 @@ describe('PublishAsKnowledgePipelineModal', () => { const { rerender } = render(<PublishAsKnowledgePipelineModal {...defaultProps} />) rerender(<PublishAsKnowledgePipelineModal {...defaultProps} />) - expect(screen.getByTestId('app-icon')).toBeInTheDocument() + // HeadlessUI Dialog renders via portal, so search the full document + expect(document.querySelector('em-emoji')).toBeInTheDocument() }) }) }) @@ -1132,12 +1014,18 @@ describe('Integration Tests', () => { />, ) - fireEvent.change(screen.getByTestId('input'), { target: { value: 'My Pipeline' } }) + fireEvent.change(getNameInput(), { target: { value: 'My Pipeline' } }) - fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'A great pipeline' } }) + fireEvent.change(getDescriptionTextarea(), { target: { value: 'A great pipeline' } }) - fireEvent.click(screen.getByTestId('app-icon')) - fireEvent.click(screen.getByTestId('select-emoji')) + // Open picker and select an emoji + const appIcon = getAppIcon() + fireEvent.click(appIcon) + const gridEmojis = document.querySelectorAll('.grid em-emoji') + if (gridEmojis.length > 0) { + fireEvent.click(gridEmojis[0].parentElement!.parentElement!) + fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) + } fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i })) @@ -1145,9 +1033,7 @@ describe('Integration Tests', () => { expect(mockOnConfirm).toHaveBeenCalledWith( 'My Pipeline', expect.objectContaining({ - icon_type: 'emoji', - icon: '🚀', - icon_background: '#000000', + icon_type: expect.any(String), }), 'A great pipeline', ) @@ -1170,7 +1056,7 @@ describe('Edge Cases', () => { />, ) - const input = screen.getByTestId('input') + const input = getNameInput() fireEvent.change(input, { target: { value: '' } }) expect(input).toHaveValue('') }) @@ -1186,7 +1072,7 @@ describe('Edge Cases', () => { ) const longName = 'A'.repeat(1000) - const input = screen.getByTestId('input') + const input = getNameInput() fireEvent.change(input, { target: { value: longName } }) expect(input).toHaveValue(longName) }) @@ -1200,7 +1086,7 @@ describe('Edge Cases', () => { ) const specialName = '<script>alert("xss")</script>' - const input = screen.getByTestId('input') + const input = getNameInput() fireEvent.change(input, { target: { value: specialName } }) expect(input).toHaveValue(specialName) }) @@ -1226,8 +1112,8 @@ describe('Accessibility', () => { />, ) - expect(screen.getByTestId('input')).toBeInTheDocument() - expect(screen.getByTestId('textarea')).toBeInTheDocument() + expect(getNameInput()).toBeInTheDocument() + expect(getDescriptionTextarea()).toBeInTheDocument() }) it('should have accessible buttons', () => { diff --git a/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx index 087f900f8a..f29d93658c 100644 --- a/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx @@ -20,6 +20,7 @@ describe('VersionMismatchModal', () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) }) describe('rendering', () => { diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx index adc249a88d..11bd554ee8 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx @@ -2,6 +2,7 @@ import type { FormData, InputFieldFormProps } from '../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import Toast from '@/app/components/base/toast' import { PipelineInputVarType } from '@/models/pipeline' import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from '../hooks' import InputFieldForm from '../index' @@ -25,12 +26,6 @@ vi.mock('@/service/use-common', () => ({ }), })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: vi.fn(), - }, -})) - const createFormData = (overrides?: Partial<FormData>): FormData => ({ type: PipelineInputVarType.textInput, label: 'Test Label', @@ -85,6 +80,12 @@ const renderHookWithProviders = <TResult,>(hook: () => TResult) => { return renderHook(hook, { wrapper: TestWrapper }) } +// Silence expected console.error from form submit preventDefault +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) +}) + describe('InputFieldForm', () => { beforeEach(() => { vi.clearAllMocks() @@ -197,7 +198,6 @@ describe('InputFieldForm', () => { }) it('should show Toast error when form validation fails on submit', async () => { - const Toast = await import('@/app/components/base/toast') const initialData = createFormData({ variable: '', // Empty variable should fail validation label: 'Test Label', @@ -210,7 +210,7 @@ describe('InputFieldForm', () => { fireEvent.submit(form) await waitFor(() => { - expect(Toast.default.notify).toHaveBeenCalledWith( + expect(Toast.notify).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', message: expect.any(String), diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx index b4332781a6..f1f45d8262 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx @@ -1,63 +1,14 @@ import type { SortableItem } from '../types' import type { InputVar } from '@/models/pipeline' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import Toast from '@/app/components/base/toast' import { PipelineInputVarType } from '@/models/pipeline' import FieldItem from '../field-item' import FieldListContainer from '../field-list-container' +import { useFieldList } from '../hooks' import FieldList from '../index' -let mockIsHovering = false -const getMockIsHovering = () => mockIsHovering - -vi.mock('ahooks', async (importOriginal) => { - const actual = await importOriginal<typeof import('ahooks')>() - return { - ...actual, - useHover: () => getMockIsHovering(), - } -}) - -vi.mock('react-sortablejs', () => ({ - ReactSortable: ({ children, list, setList, disabled, className }: { - children: React.ReactNode - list: SortableItem[] - setList: (newList: SortableItem[]) => void - disabled?: boolean - className?: string - }) => ( - <div - data-testid="sortable-container" - data-disabled={disabled} - className={className} - > - {children} - <button - data-testid="trigger-sort" - onClick={() => { - if (!disabled && list.length > 1) { - const newList = [...list] - const temp = newList[0] - newList[0] = newList[1] - newList[1] = temp - setList(newList) - } - }} - > - Trigger Sort - </button> - <button - data-testid="trigger-same-sort" - onClick={() => { - setList([...list]) - }} - > - Trigger Same Sort - </button> - </div> - ), -})) - const mockHandleInputVarRename = vi.fn() const mockIsVarUsedInNodes = vi.fn(() => false) const mockRemoveUsedVarInNodes = vi.fn() @@ -78,12 +29,6 @@ vi.mock('@/app/components/rag-pipeline/hooks', () => ({ }), })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: vi.fn(), - }, -})) - vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm', () => ({ default: ({ isShow, @@ -139,10 +84,15 @@ const createSortableItem = ( ...overrides, }) +// Silence expected console.error from form submission handlers +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) +}) + describe('FieldItem', () => { beforeEach(() => { vi.clearAllMocks() - mockIsHovering = false }) describe('Rendering', () => { @@ -192,7 +142,6 @@ describe('FieldItem', () => { }) it('should render required badge when not hovering and required is true', () => { - mockIsHovering = false const payload = createInputVar({ required: true }) render( @@ -208,7 +157,6 @@ describe('FieldItem', () => { }) it('should not render required badge when required is false', () => { - mockIsHovering = false const payload = createInputVar({ required: false }) render( @@ -224,7 +172,6 @@ describe('FieldItem', () => { }) it('should render InputField icon when not hovering', () => { - mockIsHovering = false const payload = createInputVar() const { container } = render( @@ -241,7 +188,6 @@ describe('FieldItem', () => { }) it('should render drag icon when hovering and not readonly', () => { - mockIsHovering = true const payload = createInputVar() const { container } = render( @@ -253,16 +199,16 @@ describe('FieldItem', () => { readonly={false} />, ) + fireEvent.mouseEnter(container.firstChild!) const icons = container.querySelectorAll('svg') expect(icons.length).toBeGreaterThan(0) }) it('should render edit and delete buttons when hovering and not readonly', () => { - mockIsHovering = true const payload = createInputVar() - render( + const { container } = render( <FieldItem payload={payload} index={0} @@ -271,16 +217,16 @@ describe('FieldItem', () => { readonly={false} />, ) + fireEvent.mouseEnter(container.firstChild!) const buttons = screen.getAllByRole('button') expect(buttons.length).toBe(2) // Edit and Delete buttons }) it('should not render edit and delete buttons when readonly', () => { - mockIsHovering = true const payload = createInputVar() - render( + const { container } = render( <FieldItem payload={payload} index={0} @@ -289,6 +235,7 @@ describe('FieldItem', () => { readonly={true} />, ) + fireEvent.mouseEnter(container.firstChild!) const buttons = screen.queryAllByRole('button') expect(buttons.length).toBe(0) @@ -297,11 +244,10 @@ describe('FieldItem', () => { describe('User Interactions', () => { it('should call onClickEdit with variable when edit button is clicked', () => { - mockIsHovering = true const onClickEdit = vi.fn() const payload = createInputVar({ variable: 'test_var' }) - render( + const { container } = render( <FieldItem payload={payload} index={0} @@ -309,6 +255,7 @@ describe('FieldItem', () => { onRemove={vi.fn()} />, ) + fireEvent.mouseEnter(container.firstChild!) const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]) // Edit button @@ -316,11 +263,10 @@ describe('FieldItem', () => { }) it('should call onRemove with index when delete button is clicked', () => { - mockIsHovering = true const onRemove = vi.fn() const payload = createInputVar() - render( + const { container } = render( <FieldItem payload={payload} index={5} @@ -328,6 +274,7 @@ describe('FieldItem', () => { onRemove={onRemove} />, ) + fireEvent.mouseEnter(container.firstChild!) const buttons = screen.getAllByRole('button') fireEvent.click(buttons[1]) // Delete button @@ -335,11 +282,10 @@ describe('FieldItem', () => { }) it('should not call onClickEdit when readonly', () => { - mockIsHovering = true const onClickEdit = vi.fn() const payload = createInputVar() - const { rerender } = render( + const { container, rerender } = render( <FieldItem payload={payload} index={0} @@ -348,6 +294,7 @@ describe('FieldItem', () => { readonly={false} />, ) + fireEvent.mouseEnter(container.firstChild!) rerender( <FieldItem @@ -363,12 +310,11 @@ describe('FieldItem', () => { }) it('should stop event propagation when edit button is clicked', () => { - mockIsHovering = true const onClickEdit = vi.fn() const parentClick = vi.fn() const payload = createInputVar() - render( + const { container } = render( <div onClick={parentClick}> <FieldItem payload={payload} @@ -378,6 +324,7 @@ describe('FieldItem', () => { /> </div>, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]) @@ -386,12 +333,11 @@ describe('FieldItem', () => { }) it('should stop event propagation when delete button is clicked', () => { - mockIsHovering = true const onRemove = vi.fn() const parentClick = vi.fn() const payload = createInputVar() - render( + const { container } = render( <div onClick={parentClick}> <FieldItem payload={payload} @@ -401,6 +347,7 @@ describe('FieldItem', () => { /> </div>, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) const buttons = screen.getAllByRole('button') fireEvent.click(buttons[1]) @@ -411,11 +358,10 @@ describe('FieldItem', () => { describe('Callback Stability', () => { it('should maintain stable handleOnClickEdit when props dont change', () => { - mockIsHovering = true const onClickEdit = vi.fn() const payload = createInputVar() - const { rerender } = render( + const { container, rerender } = render( <FieldItem payload={payload} index={0} @@ -423,6 +369,7 @@ describe('FieldItem', () => { onRemove={vi.fn()} />, ) + fireEvent.mouseEnter(container.firstChild!) const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]) @@ -434,6 +381,7 @@ describe('FieldItem', () => { onRemove={vi.fn()} />, ) + fireEvent.mouseEnter(container.firstChild!) const buttonsAfterRerender = screen.getAllByRole('button') fireEvent.click(buttonsAfterRerender[0]) @@ -573,10 +521,9 @@ describe('FieldItem', () => { describe('Readonly Mode Behavior', () => { it('should not render action buttons in readonly mode even when hovering', () => { - mockIsHovering = true const payload = createInputVar() - render( + const { container } = render( <FieldItem payload={payload} index={0} @@ -585,15 +532,15 @@ describe('FieldItem', () => { readonly={true} />, ) + fireEvent.mouseEnter(container.firstChild!) expect(screen.queryAllByRole('button')).toHaveLength(0) }) it('should render type icon and required badge in readonly mode when hovering', () => { - mockIsHovering = true const payload = createInputVar({ required: true }) - render( + const { container } = render( <FieldItem payload={payload} index={0} @@ -602,6 +549,7 @@ describe('FieldItem', () => { readonly={true} />, ) + fireEvent.mouseEnter(container.firstChild!) expect(screen.getByText(/required/i)).toBeInTheDocument() }) @@ -624,7 +572,6 @@ describe('FieldItem', () => { }) it('should apply cursor-all-scroll class when hovering and not readonly', () => { - mockIsHovering = true const payload = createInputVar() const { container } = render( @@ -636,6 +583,7 @@ describe('FieldItem', () => { readonly={false} />, ) + fireEvent.mouseEnter(container.firstChild!) const fieldItem = container.firstChild as HTMLElement expect(fieldItem.className).toContain('cursor-all-scroll') @@ -646,11 +594,10 @@ describe('FieldItem', () => { describe('FieldListContainer', () => { beforeEach(() => { vi.clearAllMocks() - mockIsHovering = false }) describe('Rendering', () => { - it('should render sortable container', () => { + it('should render sortable container with field items', () => { const inputFields = createInputVarList(2) render( @@ -662,7 +609,8 @@ describe('FieldListContainer', () => { />, ) - expect(screen.getByTestId('sortable-container')).toBeInTheDocument() + expect(screen.getByText('var_0')).toBeInTheDocument() + expect(screen.getByText('var_1')).toBeInTheDocument() }) it('should render all field items', () => { @@ -683,7 +631,7 @@ describe('FieldListContainer', () => { }) it('should render empty list without errors', () => { - render( + const { container } = render( <FieldListContainer inputFields={[]} onListSortChange={vi.fn()} @@ -692,13 +640,14 @@ describe('FieldListContainer', () => { />, ) - expect(screen.getByTestId('sortable-container')).toBeInTheDocument() + // ReactSortable renders a wrapper div even for empty lists + expect(container.firstChild).toBeInTheDocument() }) it('should apply custom className', () => { const inputFields = createInputVarList(1) - render( + const { container } = render( <FieldListContainer className="custom-class" inputFields={inputFields} @@ -708,14 +657,15 @@ describe('FieldListContainer', () => { />, ) - const container = screen.getByTestId('sortable-container') - expect(container.className).toContain('custom-class') + // ReactSortable renders a wrapper div with the className prop + const sortableWrapper = container.firstChild as HTMLElement + expect(sortableWrapper.className).toContain('custom-class') }) it('should disable sorting when readonly is true', () => { const inputFields = createInputVarList(2) - render( + const { container } = render( <FieldListContainer inputFields={inputFields} onListSortChange={vi.fn()} @@ -725,87 +675,18 @@ describe('FieldListContainer', () => { />, ) - const container = screen.getByTestId('sortable-container') - expect(container.dataset.disabled).toBe('true') + // Verify readonly is reflected: hovering should not show action buttons + fireEvent.mouseEnter(container.querySelector('.handle')!) + expect(screen.queryAllByRole('button')).toHaveLength(0) }) }) describe('User Interactions', () => { - it('should call onListSortChange when items are reordered', () => { - const inputFields = createInputVarList(2) - const onListSortChange = vi.fn() - - render( - <FieldListContainer - inputFields={inputFields} - onListSortChange={onListSortChange} - onRemoveField={vi.fn()} - onEditField={vi.fn()} - />, - ) - fireEvent.click(screen.getByTestId('trigger-sort')) - - expect(onListSortChange).toHaveBeenCalled() - }) - - it('should not call onListSortChange when list hasnt changed', () => { - const inputFields = [createInputVar()] - const onListSortChange = vi.fn() - - render( - <FieldListContainer - inputFields={inputFields} - onListSortChange={onListSortChange} - onRemoveField={vi.fn()} - onEditField={vi.fn()} - />, - ) - fireEvent.click(screen.getByTestId('trigger-sort')) - - expect(onListSortChange).not.toHaveBeenCalled() - }) - - it('should not call onListSortChange when disabled', () => { - const inputFields = createInputVarList(2) - const onListSortChange = vi.fn() - - render( - <FieldListContainer - inputFields={inputFields} - onListSortChange={onListSortChange} - onRemoveField={vi.fn()} - onEditField={vi.fn()} - readonly={true} - />, - ) - fireEvent.click(screen.getByTestId('trigger-sort')) - - expect(onListSortChange).not.toHaveBeenCalled() - }) - - it('should not call onListSortChange when list order is unchanged (isEqual check)', () => { - const inputFields = createInputVarList(2) - const onListSortChange = vi.fn() - - render( - <FieldListContainer - inputFields={inputFields} - onListSortChange={onListSortChange} - onRemoveField={vi.fn()} - onEditField={vi.fn()} - />, - ) - fireEvent.click(screen.getByTestId('trigger-same-sort')) - - expect(onListSortChange).not.toHaveBeenCalled() - }) - it('should pass onEditField to FieldItem', () => { - mockIsHovering = true const inputFields = createInputVarList(1) const onEditField = vi.fn() - render( + const { container } = render( <FieldListContainer inputFields={inputFields} onListSortChange={vi.fn()} @@ -813,6 +694,7 @@ describe('FieldListContainer', () => { onEditField={onEditField} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]) // Edit button @@ -820,11 +702,10 @@ describe('FieldListContainer', () => { }) it('should pass onRemoveField to FieldItem', () => { - mockIsHovering = true const inputFields = createInputVarList(1) const onRemoveField = vi.fn() - render( + const { container } = render( <FieldListContainer inputFields={inputFields} onListSortChange={vi.fn()} @@ -832,6 +713,7 @@ describe('FieldListContainer', () => { onEditField={vi.fn()} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) const buttons = screen.getAllByRole('button') fireEvent.click(buttons[1]) // Delete button @@ -840,28 +722,23 @@ describe('FieldListContainer', () => { }) describe('List Conversion', () => { - it('should convert InputVar[] to SortableItem[]', () => { - const inputFields = [ - createInputVar({ variable: 'var1' }), - createInputVar({ variable: 'var2' }), - ] - const onListSortChange = vi.fn() + it('should convert InputVar[] to SortableItem[] with correct structure', () => { + // Verify the conversion contract: id from variable, default sortable flags + const inputFields = createInputVarList(2) + const converted: SortableItem[] = inputFields.map(content => ({ + id: content.variable, + chosen: false, + selected: false, + ...content, + })) - render( - <FieldListContainer - inputFields={inputFields} - onListSortChange={onListSortChange} - onRemoveField={vi.fn()} - onEditField={vi.fn()} - />, - ) - fireEvent.click(screen.getByTestId('trigger-sort')) - - expect(onListSortChange).toHaveBeenCalled() - const calledWith = onListSortChange.mock.calls[0][0] - expect(calledWith[0]).toHaveProperty('id') - expect(calledWith[0]).toHaveProperty('chosen') - expect(calledWith[0]).toHaveProperty('selected') + expect(converted).toHaveLength(2) + expect(converted[0].id).toBe('var_0') + expect(converted[0].chosen).toBe(false) + expect(converted[0].selected).toBe(false) + expect(converted[0].variable).toBe('var_0') + expect(converted[0].type).toBe(PipelineInputVarType.textInput) + expect(converted[1].id).toBe('var_1') }) }) @@ -951,7 +828,6 @@ describe('FieldListContainer', () => { describe('FieldList', () => { beforeEach(() => { vi.clearAllMocks() - mockIsHovering = false mockIsVarUsedInNodes.mockReturnValue(false) }) @@ -1078,34 +954,36 @@ describe('FieldList', () => { describe('Callback Handling', () => { it('should call handleInputFieldsChange with nodeId when fields change', () => { + mockIsVarUsedInNodes.mockReturnValue(false) const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList - nodeId="node-123" + nodeId="node-1" LabelRightContent={null} inputFields={inputFields} handleInputFieldsChange={handleInputFieldsChange} allVariableNames={[]} />, ) - fireEvent.click(screen.getByTestId('trigger-sort')) - expect(handleInputFieldsChange).toHaveBeenCalledWith( - 'node-123', - expect.any(Array), - ) + // Trigger field change via remove action + fireEvent.mouseEnter(container.querySelector('.handle')!) + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') + if (fieldItemButtons.length >= 2) + fireEvent.click(fieldItemButtons[1]) + + expect(handleInputFieldsChange).toHaveBeenCalledWith('node-1', expect.any(Array)) }) }) describe('Remove Confirmation', () => { it('should show remove confirmation when variable is used in nodes', async () => { mockIsVarUsedInNodes.mockReturnValue(true) - mockIsHovering = true const inputFields = createInputVarList(1) - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1114,9 +992,9 @@ describe('FieldList', () => { allVariableNames={[]} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -1127,10 +1005,9 @@ describe('FieldList', () => { it('should hide remove confirmation when cancel is clicked', async () => { mockIsVarUsedInNodes.mockReturnValue(true) - mockIsHovering = true const inputFields = createInputVarList(1) - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1139,9 +1016,9 @@ describe('FieldList', () => { allVariableNames={[]} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -1158,11 +1035,10 @@ describe('FieldList', () => { it('should remove field and call removeUsedVarInNodes when confirm is clicked', async () => { mockIsVarUsedInNodes.mockReturnValue(true) - mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1171,9 +1047,9 @@ describe('FieldList', () => { allVariableNames={[]} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -1191,11 +1067,10 @@ describe('FieldList', () => { it('should remove field directly when variable is not used in nodes', () => { mockIsVarUsedInNodes.mockReturnValue(false) - mockIsHovering = true const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1204,9 +1079,9 @@ describe('FieldList', () => { allVariableNames={[]} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -1217,7 +1092,7 @@ describe('FieldList', () => { describe('Edge Cases', () => { it('should handle empty inputFields', () => { - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1227,7 +1102,8 @@ describe('FieldList', () => { />, ) - expect(screen.getByTestId('sortable-container')).toBeInTheDocument() + // Component renders without errors even with no fields + expect(container.firstChild).toBeInTheDocument() }) it('should handle null LabelRightContent', () => { @@ -1296,10 +1172,11 @@ describe('FieldList', () => { }) it('should maintain stable onInputFieldsChange callback', () => { - const inputFields = createInputVarList(2) + mockIsVarUsedInNodes.mockReturnValue(false) const handleInputFieldsChange = vi.fn() + const inputFields = createInputVarList(2) - const { rerender } = render( + const { rerender, container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1309,8 +1186,7 @@ describe('FieldList', () => { />, ) - fireEvent.click(screen.getByTestId('trigger-sort')) - + // Rerender with same props to verify callback stability rerender( <FieldList nodeId="node-1" @@ -1321,9 +1197,13 @@ describe('FieldList', () => { />, ) - fireEvent.click(screen.getByTestId('trigger-sort')) + // After rerender, the callback chain should still work correctly + fireEvent.mouseEnter(container.querySelector('.handle')!) + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') + if (fieldItemButtons.length >= 2) + fireEvent.click(fieldItemButtons[1]) - expect(handleInputFieldsChange).toHaveBeenCalledTimes(2) + expect(handleInputFieldsChange).toHaveBeenCalledWith('node-1', expect.any(Array)) }) }) }) @@ -1353,7 +1233,7 @@ describe('useFieldList Hook', () => { }) it('should initialize with empty inputFields', () => { - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1363,64 +1243,72 @@ describe('useFieldList Hook', () => { />, ) - expect(screen.getByTestId('sortable-container')).toBeInTheDocument() + // Component renders without errors even with no fields + expect(container.firstChild).toBeInTheDocument() }) }) describe('handleListSortChange', () => { it('should update inputFields and call onInputFieldsChange', () => { - const inputFields = createInputVarList(2) - const handleInputFieldsChange = vi.fn() + const onInputFieldsChange = vi.fn() + const initialFields = createInputVarList(2) - render( - <FieldList - nodeId="node-1" - LabelRightContent={null} - inputFields={inputFields} - handleInputFieldsChange={handleInputFieldsChange} - allVariableNames={[]} - />, - ) - fireEvent.click(screen.getByTestId('trigger-sort')) + const { result } = renderHook(() => useFieldList({ + initialInputFields: initialFields, + onInputFieldsChange, + nodeId: 'node-1', + allVariableNames: [], + })) - expect(handleInputFieldsChange).toHaveBeenCalledWith( - 'node-1', - expect.arrayContaining([ - expect.objectContaining({ variable: 'var_1' }), - expect.objectContaining({ variable: 'var_0' }), - ]), - ) + // Simulate sort change by calling handleListSortChange directly + const reorderedList: SortableItem[] = [ + createSortableItem(initialFields[1]), + createSortableItem(initialFields[0]), + ] + + act(() => { + result.current.handleListSortChange(reorderedList) + }) + + expect(onInputFieldsChange).toHaveBeenCalledWith([ + expect.objectContaining({ variable: 'var_1' }), + expect.objectContaining({ variable: 'var_0' }), + ]) }) it('should strip sortable properties from list items', () => { - const inputFields = createInputVarList(2) - const handleInputFieldsChange = vi.fn() + const onInputFieldsChange = vi.fn() + const initialFields = createInputVarList(1) - render( - <FieldList - nodeId="node-1" - LabelRightContent={null} - inputFields={inputFields} - handleInputFieldsChange={handleInputFieldsChange} - allVariableNames={[]} - />, - ) - fireEvent.click(screen.getByTestId('trigger-sort')) + const { result } = renderHook(() => useFieldList({ + initialInputFields: initialFields, + onInputFieldsChange, + nodeId: 'node-1', + allVariableNames: [], + })) - const calledWith = handleInputFieldsChange.mock.calls[0][1] - expect(calledWith[0]).not.toHaveProperty('id') - expect(calledWith[0]).not.toHaveProperty('chosen') - expect(calledWith[0]).not.toHaveProperty('selected') + const sortableList: SortableItem[] = [ + createSortableItem(initialFields[0], { chosen: true, selected: true }), + ] + + act(() => { + result.current.handleListSortChange(sortableList) + }) + + const updatedFields = onInputFieldsChange.mock.calls[0][0] + expect(updatedFields[0]).not.toHaveProperty('id') + expect(updatedFields[0]).not.toHaveProperty('chosen') + expect(updatedFields[0]).not.toHaveProperty('selected') + expect(updatedFields[0]).toHaveProperty('variable', 'var_0') }) }) describe('handleRemoveField', () => { it('should show confirmation when variable is used', async () => { mockIsVarUsedInNodes.mockReturnValue(true) - mockIsHovering = true const inputFields = createInputVarList(1) - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1429,9 +1317,9 @@ describe('useFieldList Hook', () => { allVariableNames={[]} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -1442,11 +1330,10 @@ describe('useFieldList Hook', () => { it('should remove directly when variable is not used', () => { mockIsVarUsedInNodes.mockReturnValue(false) - mockIsHovering = true const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1455,9 +1342,9 @@ describe('useFieldList Hook', () => { allVariableNames={[]} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -1467,11 +1354,10 @@ describe('useFieldList Hook', () => { it('should not call handleInputFieldsChange immediately when variable is used (lines 70-72)', async () => { mockIsVarUsedInNodes.mockReturnValue(true) - mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1480,9 +1366,9 @@ describe('useFieldList Hook', () => { allVariableNames={[]} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -1494,10 +1380,9 @@ describe('useFieldList Hook', () => { it('should call isVarUsedInNodes with correct variable selector', async () => { mockIsVarUsedInNodes.mockReturnValue(true) - mockIsHovering = true const inputFields = [createInputVar({ variable: 'my_test_var' })] - render( + const { container } = render( <FieldList nodeId="test-node-123" LabelRightContent={null} @@ -1506,9 +1391,9 @@ describe('useFieldList Hook', () => { allVariableNames={[]} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -1517,11 +1402,10 @@ describe('useFieldList Hook', () => { it('should handle empty variable name gracefully', async () => { mockIsVarUsedInNodes.mockReturnValue(false) - mockIsHovering = true const inputFields = [createInputVar({ variable: '' })] const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1530,9 +1414,9 @@ describe('useFieldList Hook', () => { allVariableNames={[]} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -1541,11 +1425,10 @@ describe('useFieldList Hook', () => { it('should set removedVar and removedIndex when showing confirmation (lines 71-73)', async () => { mockIsVarUsedInNodes.mockReturnValue(true) - mockIsHovering = true const inputFields = createInputVarList(3) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1554,9 +1437,10 @@ describe('useFieldList Hook', () => { allVariableNames={[]} />, ) + const fieldItemRoots = container.querySelectorAll('.handle') + fieldItemRoots.forEach(el => fireEvent.mouseEnter(el)) - const sortableContainer = screen.getByTestId('sortable-container') - const allFieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const allFieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (allFieldItemButtons.length >= 4) fireEvent.click(allFieldItemButtons[3]) @@ -1603,10 +1487,9 @@ describe('useFieldList Hook', () => { }) it('should pass initialData when editing existing field', () => { - mockIsHovering = true const inputFields = [createInputVar({ variable: 'my_var', label: 'My Label' })] - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1615,8 +1498,8 @@ describe('useFieldList Hook', () => { allVariableNames={[]} />, ) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + fireEvent.mouseEnter(container.querySelector('.handle')!) + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) @@ -1634,11 +1517,10 @@ describe('useFieldList Hook', () => { describe('onRemoveVarConfirm', () => { it('should remove field and call removeUsedVarInNodes', async () => { mockIsVarUsedInNodes.mockReturnValue(true) - mockIsHovering = true const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1647,9 +1529,9 @@ describe('useFieldList Hook', () => { allVariableNames={[]} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 2) fireEvent.click(fieldItemButtons[1]) @@ -1671,7 +1553,6 @@ describe('handleSubmitField', () => { beforeEach(() => { vi.clearAllMocks() mockIsVarUsedInNodes.mockReturnValue(false) - mockIsHovering = false }) it('should add new field when editingFieldIndex is -1', () => { @@ -1707,11 +1588,10 @@ describe('handleSubmitField', () => { }) it('should update existing field when editingFieldIndex is valid', () => { - mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1720,9 +1600,9 @@ describe('handleSubmitField', () => { allVariableNames={['var_0']} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) @@ -1742,11 +1622,10 @@ describe('handleSubmitField', () => { }) it('should call handleInputVarRename when variable name changes', () => { - mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1755,9 +1634,9 @@ describe('handleSubmitField', () => { allVariableNames={['var_0']} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) @@ -1777,11 +1656,10 @@ describe('handleSubmitField', () => { }) it('should not call handleInputVarRename when moreInfo type is not changeVarName', () => { - mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1790,9 +1668,9 @@ describe('handleSubmitField', () => { allVariableNames={['var_0']} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) @@ -1806,11 +1684,10 @@ describe('handleSubmitField', () => { }) it('should not call handleInputVarRename when moreInfo has different type', () => { - mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1819,9 +1696,9 @@ describe('handleSubmitField', () => { allVariableNames={['var_0']} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) @@ -1835,11 +1712,10 @@ describe('handleSubmitField', () => { }) it('should handle empty beforeKey and afterKey in moreInfo payload', () => { - mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1848,9 +1724,9 @@ describe('handleSubmitField', () => { allVariableNames={['var_0']} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) @@ -1870,11 +1746,10 @@ describe('handleSubmitField', () => { }) it('should handle undefined payload in moreInfo', () => { - mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -1883,9 +1758,9 @@ describe('handleSubmitField', () => { allVariableNames={['var_0']} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) @@ -1957,11 +1832,9 @@ describe('Duplicate Variable Name Handling', () => { beforeEach(() => { vi.clearAllMocks() mockIsVarUsedInNodes.mockReturnValue(false) - mockIsHovering = false }) - it('should not add field if variable name is duplicate', async () => { - const Toast = await import('@/app/components/base/toast') + it('should not add field if variable name is duplicate', () => { const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() @@ -1983,17 +1856,16 @@ describe('Duplicate Variable Name Handling', () => { editorProps.onSubmit(duplicateFieldData) expect(handleInputFieldsChange).not.toHaveBeenCalled() - expect(Toast.default.notify).toHaveBeenCalledWith( + expect(Toast.notify).toHaveBeenCalledWith( expect.objectContaining({ type: 'error' }), ) }) it('should allow updating field to same variable name', () => { - mockIsHovering = true const inputFields = createInputVarList(2) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={null} @@ -2002,9 +1874,9 @@ describe('Duplicate Variable Name Handling', () => { allVariableNames={['var_0', 'var_1']} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 1) fireEvent.click(fieldItemButtons[0]) @@ -2045,17 +1917,15 @@ describe('SortableItem Type', () => { describe('Integration Tests', () => { beforeEach(() => { vi.clearAllMocks() - mockIsHovering = false mockIsVarUsedInNodes.mockReturnValue(false) }) describe('Complete Workflow', () => { it('should handle add -> edit -> remove workflow', async () => { - mockIsHovering = true const inputFields = createInputVarList(1) const handleInputFieldsChange = vi.fn() - render( + const { container } = render( <FieldList nodeId="node-1" LabelRightContent={<span>Fields</span>} @@ -2064,12 +1934,12 @@ describe('Integration Tests', () => { allVariableNames={['var_0']} />, ) + fireEvent.mouseEnter(container.querySelector('.handle')!) fireEvent.click(screen.getByTestId('field-list-add-btn')) expect(mockToggleInputFieldEditPanel).toHaveBeenCalled() - const sortableContainer = screen.getByTestId('sortable-container') - const fieldItemButtons = sortableContainer.querySelectorAll('button.action-btn') + const fieldItemButtons = container.querySelectorAll('.handle button.action-btn') if (fieldItemButtons.length >= 1) { fireEvent.click(fieldItemButtons[0]) expect(mockToggleInputFieldEditPanel).toHaveBeenCalledTimes(2) @@ -2080,31 +1950,6 @@ describe('Integration Tests', () => { expect(handleInputFieldsChange).toHaveBeenCalled() }) - - it('should handle sort operation correctly', () => { - const inputFields = createInputVarList(3) - const handleInputFieldsChange = vi.fn() - - render( - <FieldList - nodeId="node-1" - LabelRightContent={null} - inputFields={inputFields} - handleInputFieldsChange={handleInputFieldsChange} - allVariableNames={[]} - />, - ) - - fireEvent.click(screen.getByTestId('trigger-sort')) - - expect(handleInputFieldsChange).toHaveBeenCalledWith( - 'node-1', - expect.any(Array), - ) - const newOrder = handleInputFieldsChange.mock.calls[0][1] - expect(newOrder[0].variable).toBe('var_1') - expect(newOrder[1].variable).toBe('var_0') - }) }) describe('Props Propagation', () => { @@ -2126,9 +1971,6 @@ describe('Integration Tests', () => { btn.querySelector('svg'), ) expect(addButton).toBeDisabled() - - const sortableContainer = screen.getByTestId('sortable-container') - expect(sortableContainer.dataset.disabled).toBe('true') }) }) }) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index 0fc3bda7b3..6129d3fe73 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ToastContext } from '@/app/components/base/toast' import Publisher from '../index' import Popup from '../popup' @@ -18,53 +19,6 @@ vi.mock('next/link', () => ({ ), })) -let keyPressCallback: ((e: KeyboardEvent) => void) | null = null -vi.mock('ahooks', () => ({ - useBoolean: (defaultValue = false) => { - const [value, setValue] = React.useState(defaultValue) - return [value, { - setTrue: () => setValue(true), - setFalse: () => setValue(false), - toggle: () => setValue(v => !v), - }] - }, - useKeyPress: (key: string, callback: (e: KeyboardEvent) => void) => { - keyPressCallback = callback - }, -})) - -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: vi.fn(), -})) - -let mockPortalOpen = false -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: { - children: React.ReactNode - open: boolean - onOpenChange: (open: boolean) => void - }) => { - mockPortalOpen = open - return <div data-testid="portal-elem" data-open={open}>{children}</div> - }, - PortalToFollowElemTrigger: ({ children, onClick }: { - children: React.ReactNode - onClick: () => void - }) => ( - <div data-testid="portal-trigger" onClick={onClick}> - {children} - </div> - ), - PortalToFollowElemContent: ({ children, className }: { - children: React.ReactNode - className?: string - }) => { - if (!mockPortalOpen) - return null - return <div data-testid="portal-content" className={className}>{children}</div> - }, -})) - const mockHandleSyncWorkflowDraft = vi.fn() const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true) vi.mock('@/app/components/workflow/hooks', () => ({ @@ -120,11 +74,6 @@ vi.mock('@/context/provider-context', () => ({ })) const mockNotify = vi.fn() -vi.mock('@/app/components/base/toast', () => ({ - useToastContext: () => ({ - notify: mockNotify, - }), -})) vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: () => 'https://api.dify.ai/v1/datasets/test-dataset-id', @@ -207,7 +156,9 @@ const renderWithQueryClient = (ui: React.ReactElement) => { const queryClient = createQueryClient() return render( <QueryClientProvider client={queryClient}> - {ui} + <ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}> + {ui} + </ToastContext.Provider> </QueryClientProvider>, ) } @@ -215,8 +166,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => { describe('publisher', () => { beforeEach(() => { vi.clearAllMocks() - mockPortalOpen = false - keyPressCallback = null + vi.spyOn(console, 'error').mockImplementation(() => {}) mockPublishedAt.mockReturnValue(null) mockDraftUpdatedAt.mockReturnValue(1700000000) mockPipelineId.mockReturnValue('test-pipeline-id') @@ -236,8 +186,9 @@ describe('publisher', () => { it('should render portal element in closed state by default', () => { renderWithQueryClient(<Publisher />) - expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + const trigger = screen.getByText('workflow.common.publish').closest('[data-state]') + expect(trigger).toHaveAttribute('data-state', 'closed') + expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument() }) it('should render down arrow icon in button', () => { @@ -252,24 +203,24 @@ describe('publisher', () => { it('should open popup when trigger is clicked', async () => { renderWithQueryClient(<Publisher />) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByText('workflow.common.publish')) await waitFor(() => { - expect(screen.getByTestId('portal-content')).toBeInTheDocument() + expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument() }) }) it('should close popup when trigger is clicked again while open', async () => { renderWithQueryClient(<Publisher />) - fireEvent.click(screen.getByTestId('portal-trigger')) // open + fireEvent.click(screen.getByText('workflow.common.publish')) // open await waitFor(() => { - expect(screen.getByTestId('portal-content')).toBeInTheDocument() + expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument() }) - fireEvent.click(screen.getByTestId('portal-trigger')) // close + fireEvent.click(screen.getByText('workflow.common.publish')) // close await waitFor(() => { - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument() }) }) }) @@ -278,20 +229,20 @@ describe('publisher', () => { it('should call handleSyncWorkflowDraft when popup opens', async () => { renderWithQueryClient(<Publisher />) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByText('workflow.common.publish')) expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true) }) it('should not call handleSyncWorkflowDraft when popup closes', async () => { renderWithQueryClient(<Publisher />) - fireEvent.click(screen.getByTestId('portal-trigger')) // open + fireEvent.click(screen.getByText('workflow.common.publish')) // open vi.clearAllMocks() await waitFor(() => { - expect(screen.getByTestId('portal-content')).toBeInTheDocument() + expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument() }) - fireEvent.click(screen.getByTestId('portal-trigger')) // close + fireEvent.click(screen.getByText('workflow.common.publish')) // close expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() }) @@ -306,10 +257,10 @@ describe('publisher', () => { it('should render popup content when opened', async () => { renderWithQueryClient(<Publisher />) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByText('workflow.common.publish')) await waitFor(() => { - expect(screen.getByTestId('portal-content')).toBeInTheDocument() + expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument() }) }) }) @@ -811,10 +762,8 @@ describe('publisher', () => { mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient(<Popup />) - const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent - keyPressCallback?.(mockEvent) + fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true }) - expect(mockEvent.preventDefault).toHaveBeenCalled() await waitFor(() => { expect(mockPublishWorkflow).toHaveBeenCalled() }) @@ -834,10 +783,8 @@ describe('publisher', () => { vi.clearAllMocks() - const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent - keyPressCallback?.(mockEvent) + fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true }) - expect(mockEvent.preventDefault).toHaveBeenCalled() expect(mockPublishWorkflow).not.toHaveBeenCalled() }) @@ -845,8 +792,7 @@ describe('publisher', () => { mockPublishedAt.mockReturnValue(null) renderWithQueryClient(<Popup />) - const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent - keyPressCallback?.(mockEvent) + fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true }) await waitFor(() => { expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() @@ -861,16 +807,14 @@ describe('publisher', () => { })) renderWithQueryClient(<Popup />) - const mockEvent1 = { preventDefault: vi.fn() } as unknown as KeyboardEvent - keyPressCallback?.(mockEvent1) + fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true }) await waitFor(() => { const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) expect(publishButton).toBeDisabled() }) - const mockEvent2 = { preventDefault: vi.fn() } as unknown as KeyboardEvent - keyPressCallback?.(mockEvent2) + fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true }) expect(mockPublishWorkflow).toHaveBeenCalledTimes(1) @@ -1066,10 +1010,10 @@ describe('publisher', () => { it('should show Publisher button and open popup with Popup component', async () => { renderWithQueryClient(<Publisher />) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByText('workflow.common.publish')) await waitFor(() => { - expect(screen.getByTestId('portal-content')).toBeInTheDocument() + expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument() }) expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts new file mode 100644 index 0000000000..bb259284dc --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts @@ -0,0 +1,99 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useInspectVarsCrud } from '../use-inspect-vars-crud' + +// Mock return value for useInspectVarsCrudCommon +const mockApis = { + hasNodeInspectVars: vi.fn(), + hasSetInspectVar: vi.fn(), + fetchInspectVarValue: vi.fn(), + editInspectVarValue: vi.fn(), + renameInspectVarName: vi.fn(), + appendNodeInspectVars: vi.fn(), + deleteInspectVar: vi.fn(), + deleteNodeInspectorVars: vi.fn(), + deleteAllInspectorVars: vi.fn(), + isInspectVarEdited: vi.fn(), + resetToLastRunVar: vi.fn(), + invalidateSysVarValues: vi.fn(), + resetConversationVar: vi.fn(), + invalidateConversationVarValues: vi.fn(), +} + +const mockUseInspectVarsCrudCommon = vi.fn(() => mockApis) +vi.mock('../../../workflow/hooks/use-inspect-vars-crud-common', () => ({ + useInspectVarsCrudCommon: (...args: Parameters<typeof mockUseInspectVarsCrudCommon>) => mockUseInspectVarsCrudCommon(...args), +})) + +const mockConfigsMap = { + flowId: 'pipeline-123', + flowType: 'rag_pipeline', + fileSettings: { + image: { enabled: false }, + fileUploadConfig: {}, + }, +} + +vi.mock('../use-configs-map', () => ({ + useConfigsMap: () => mockConfigsMap, +})) + +describe('useInspectVarsCrud', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify the hook composes useConfigsMap with useInspectVarsCrudCommon + describe('Composition', () => { + it('should pass configsMap to useInspectVarsCrudCommon', () => { + renderHook(() => useInspectVarsCrud()) + + expect(mockUseInspectVarsCrudCommon).toHaveBeenCalledWith( + expect.objectContaining({ + flowId: 'pipeline-123', + flowType: 'rag_pipeline', + }), + ) + }) + + it('should return all APIs from useInspectVarsCrudCommon', () => { + const { result } = renderHook(() => useInspectVarsCrud()) + + expect(result.current.hasNodeInspectVars).toBe(mockApis.hasNodeInspectVars) + expect(result.current.fetchInspectVarValue).toBe(mockApis.fetchInspectVarValue) + expect(result.current.editInspectVarValue).toBe(mockApis.editInspectVarValue) + expect(result.current.deleteInspectVar).toBe(mockApis.deleteInspectVar) + expect(result.current.deleteAllInspectorVars).toBe(mockApis.deleteAllInspectorVars) + expect(result.current.resetToLastRunVar).toBe(mockApis.resetToLastRunVar) + expect(result.current.resetConversationVar).toBe(mockApis.resetConversationVar) + }) + }) + + // Verify the hook spreads all returned properties + describe('API Surface', () => { + it('should expose all expected API methods', () => { + const { result } = renderHook(() => useInspectVarsCrud()) + + const expectedKeys = [ + 'hasNodeInspectVars', + 'hasSetInspectVar', + 'fetchInspectVarValue', + 'editInspectVarValue', + 'renameInspectVarName', + 'appendNodeInspectVars', + 'deleteInspectVar', + 'deleteNodeInspectorVars', + 'deleteAllInspectorVars', + 'isInspectVarEdited', + 'resetToLastRunVar', + 'invalidateSysVarValues', + 'resetConversationVar', + 'invalidateConversationVarValues', + ] + + for (const key of expectedKeys) + expect(result.current).toHaveProperty(key) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts index 1ed50e820f..9707ad0702 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts @@ -46,6 +46,7 @@ describe('usePipelineInit', () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) mockWorkflowStoreGetState.mockReturnValue({ setEnvSecrets: mockSetEnvSecrets, diff --git a/web/app/components/signin/countdown.spec.tsx b/web/app/components/signin/__tests__/countdown.spec.tsx similarity index 81% rename from web/app/components/signin/countdown.spec.tsx rename to web/app/components/signin/__tests__/countdown.spec.tsx index 7a3496f72a..7d5e847b72 100644 --- a/web/app/components/signin/countdown.spec.tsx +++ b/web/app/components/signin/__tests__/countdown.spec.tsx @@ -1,26 +1,17 @@ import { act, fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import Countdown, { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from './countdown' - -// Mock useCountDown from ahooks -let mockTime = COUNT_DOWN_TIME_MS -let mockOnEnd: (() => void) | undefined - -vi.mock('ahooks', () => ({ - useCountDown: ({ onEnd }: { leftTime: number, onEnd?: () => void }) => { - mockOnEnd = onEnd - return [mockTime] - }, -})) +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Countdown, { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../countdown' describe('Countdown', () => { beforeEach(() => { - vi.clearAllMocks() - mockTime = COUNT_DOWN_TIME_MS - mockOnEnd = undefined + vi.useFakeTimers() localStorage.clear() }) + afterEach(() => { + vi.useRealTimers() + }) + // Rendering Tests describe('Rendering', () => { it('should render without crashing', () => { @@ -29,16 +20,15 @@ describe('Countdown', () => { }) it('should display countdown time when time > 0', () => { - mockTime = 30000 // 30 seconds + localStorage.setItem(COUNT_DOWN_KEY, '30000') render(<Countdown />) - // The countdown displays number and 's' in the same span expect(screen.getByText(/30/)).toBeInTheDocument() expect(screen.getByText(/s$/)).toBeInTheDocument() }) it('should display resend link when time <= 0', () => { - mockTime = 0 + localStorage.setItem(COUNT_DOWN_KEY, '0') render(<Countdown />) expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument() @@ -46,7 +36,7 @@ describe('Countdown', () => { }) it('should not display resend link when time > 0', () => { - mockTime = 1000 + localStorage.setItem(COUNT_DOWN_KEY, '1000') render(<Countdown />) expect(screen.queryByText('login.checkCode.resend')).not.toBeInTheDocument() @@ -57,7 +47,7 @@ describe('Countdown', () => { describe('State Management', () => { it('should initialize leftTime from localStorage if available', () => { const savedTime = 45000 - vi.mocked(localStorage.getItem).mockReturnValueOnce(String(savedTime)) + localStorage.setItem(COUNT_DOWN_KEY, String(savedTime)) render(<Countdown />) @@ -65,25 +55,26 @@ describe('Countdown', () => { }) it('should use default COUNT_DOWN_TIME_MS when localStorage is empty', () => { - vi.mocked(localStorage.getItem).mockReturnValueOnce(null) - render(<Countdown />) expect(localStorage.getItem).toHaveBeenCalledWith(COUNT_DOWN_KEY) }) it('should save time to localStorage on time change', () => { - mockTime = 50000 render(<Countdown />) - expect(localStorage.setItem).toHaveBeenCalledWith(COUNT_DOWN_KEY, String(mockTime)) + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(localStorage.setItem).toHaveBeenCalledWith(COUNT_DOWN_KEY, expect.any(String)) }) }) // Event Handler Tests describe('Event Handlers', () => { it('should call onResend callback when resend is clicked', () => { - mockTime = 0 + localStorage.setItem(COUNT_DOWN_KEY, '0') const onResend = vi.fn() render(<Countdown onResend={onResend} />) @@ -95,7 +86,7 @@ describe('Countdown', () => { }) it('should reset countdown when resend is clicked', () => { - mockTime = 0 + localStorage.setItem(COUNT_DOWN_KEY, '0') render(<Countdown />) @@ -106,7 +97,7 @@ describe('Countdown', () => { }) it('should work without onResend callback (optional prop)', () => { - mockTime = 0 + localStorage.setItem(COUNT_DOWN_KEY, '0') render(<Countdown />) @@ -118,11 +109,12 @@ describe('Countdown', () => { // Countdown End Tests describe('Countdown End', () => { it('should remove localStorage item when countdown ends', () => { + localStorage.setItem(COUNT_DOWN_KEY, '1000') + render(<Countdown />) - // Simulate countdown end act(() => { - mockOnEnd?.() + vi.advanceTimersByTime(2000) }) expect(localStorage.removeItem).toHaveBeenCalledWith(COUNT_DOWN_KEY) @@ -132,28 +124,28 @@ describe('Countdown', () => { // Edge Cases describe('Edge Cases', () => { it('should handle time exactly at 0', () => { - mockTime = 0 + localStorage.setItem(COUNT_DOWN_KEY, '0') render(<Countdown />) expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument() }) it('should handle negative time values', () => { - mockTime = -1000 + localStorage.setItem(COUNT_DOWN_KEY, '-1000') render(<Countdown />) expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument() }) it('should round time display correctly', () => { - mockTime = 29500 // Should display as 30 (Math.round) + localStorage.setItem(COUNT_DOWN_KEY, '29500') render(<Countdown />) expect(screen.getByText(/30/)).toBeInTheDocument() }) it('should display 1 second correctly', () => { - mockTime = 1000 + localStorage.setItem(COUNT_DOWN_KEY, '1000') render(<Countdown />) expect(screen.getByText(/^1/)).toBeInTheDocument() @@ -163,8 +155,8 @@ describe('Countdown', () => { // Props Tests describe('Props', () => { it('should render correctly with onResend prop', () => { + localStorage.setItem(COUNT_DOWN_KEY, '0') const onResend = vi.fn() - mockTime = 0 render(<Countdown onResend={onResend} />) diff --git a/web/app/components/tools/__tests__/provider-list.spec.tsx b/web/app/components/tools/__tests__/provider-list.spec.tsx index ad703bf43a..2c75c20979 100644 --- a/web/app/components/tools/__tests__/provider-list.spec.tsx +++ b/web/app/components/tools/__tests__/provider-list.spec.tsx @@ -1,14 +1,9 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ToolTypeEnum } from '../../workflow/block-selector/types' import ProviderList from '../provider-list' - -let mockActiveTab = 'builtin' -const mockSetActiveTab = vi.fn((val: string) => { - mockActiveTab = val -}) -vi.mock('nuqs', () => ({ - useQueryState: () => [mockActiveTab, mockSetActiveTab], -})) +import { getToolType } from '../utils' vi.mock('@/app/components/plugins/hooks', () => ({ useTags: () => ({ @@ -18,11 +13,13 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) +let mockEnableMarketplace = false vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ enable_marketplace: false }), + useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) => + selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }), })) -const mockCollections = [ +const createDefaultCollections = () => [ { id: 'builtin-1', name: 'google-search', @@ -36,6 +33,33 @@ const mockCollections = [ allow_delete: false, labels: ['search'], }, + { + id: 'builtin-2', + name: 'weather-tool', + author: 'Dify', + description: { en_US: 'Weather Tool', zh_Hans: 'ć€©æ°”ć·„ć…·' }, + icon: 'icon-weather', + label: { en_US: 'Weather Tool', zh_Hans: 'ć€©æ°”ć·„ć…·' }, + type: 'builtin', + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: ['utility'], + }, + { + id: 'builtin-plugin', + name: 'plugin-tool', + author: 'Dify', + description: { en_US: 'Plugin Tool', zh_Hans: 'æ’ä»¶ć·„ć…·' }, + icon: 'icon-plugin', + label: { en_US: 'Plugin Tool', zh_Hans: 'æ’ä»¶ć·„ć…·' }, + type: 'builtin', + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + plugin_id: 'org/plugin-tool', + }, { id: 'api-1', name: 'my-api', @@ -64,38 +88,22 @@ const mockCollections = [ }, ] +let mockCollectionData: ReturnType<typeof createDefaultCollections> = [] const mockRefetch = vi.fn() vi.mock('@/service/use-tools', () => ({ useAllToolProviders: () => ({ - data: mockCollections, + data: mockCollectionData, refetch: mockRefetch, }), })) +let mockCheckedInstalledData: { plugins: { id: string, name: string }[] } | null = null +const mockInvalidateInstalledPluginList = vi.fn() 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> - ), + useCheckInstalled: ({ enabled }: { enabled: boolean }) => ({ + data: enabled ? mockCheckedInstalledData : null, + }), + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, })) vi.mock('@/app/components/plugins/card', () => ({ @@ -136,16 +144,33 @@ vi.mock('@/app/components/tools/provider/empty', () => ({ })) vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ - default: ({ detail }: { detail: unknown }) => - detail ? <div data-testid="plugin-detail-panel" /> : null, + default: ({ detail, onUpdate, onHide }: { detail: unknown, onUpdate: () => void, onHide: () => void }) => + detail + ? ( + <div data-testid="plugin-detail-panel"> + <button data-testid="plugin-update" onClick={onUpdate}>Update</button> + <button data-testid="plugin-close" onClick={onHide}>Close</button> + </div> + ) + : null, })) vi.mock('@/app/components/plugins/marketplace/empty', () => ({ default: ({ text }: { text: string }) => <div data-testid="empty">{text}</div>, })) +const mockHandleScroll = vi.fn() vi.mock('../marketplace', () => ({ - default: () => <div data-testid="marketplace">Marketplace</div>, + default: ({ showMarketplacePanel, isMarketplaceArrowVisible }: { + showMarketplacePanel: () => void + isMarketplaceArrowVisible: boolean + }) => ( + <div data-testid="marketplace"> + <button data-testid="marketplace-arrow" onClick={showMarketplacePanel}> + {isMarketplaceArrowVisible ? 'arrow-visible' : 'arrow-hidden'} + </button> + </div> + ), })) vi.mock('../marketplace/hooks', () => ({ @@ -154,7 +179,7 @@ vi.mock('../marketplace/hooks', () => ({ marketplaceCollections: [], marketplaceCollectionPluginsMap: {}, plugins: [], - handleScroll: vi.fn(), + handleScroll: mockHandleScroll, page: 1, }), })) @@ -168,10 +193,33 @@ vi.mock('../mcp', () => ({ ), })) +describe('getToolType', () => { + it.each([ + ['builtin', ToolTypeEnum.BuiltIn], + ['api', ToolTypeEnum.Custom], + ['workflow', ToolTypeEnum.Workflow], + ['mcp', ToolTypeEnum.MCP], + ['unknown', ToolTypeEnum.BuiltIn], + ])('returns correct ToolTypeEnum for "%s"', (input, expected) => { + expect(getToolType(input)).toBe(expected) + }) +}) + +const renderProviderList = (searchParams?: Record<string, string>) => { + return render( + <NuqsTestingAdapter searchParams={searchParams}> + <ProviderList /> + </NuqsTestingAdapter>, + ) +} + describe('ProviderList', () => { beforeEach(() => { vi.clearAllMocks() - mockActiveTab = 'builtin' + mockEnableMarketplace = false + mockCollectionData = createDefaultCollections() + mockCheckedInstalledData = null + Element.prototype.scrollTo = vi.fn() }) afterEach(() => { @@ -180,84 +228,239 @@ describe('ProviderList', () => { describe('Tab Navigation', () => { it('renders all four tabs', () => { - render(<ProviderList />) - expect(screen.getByTestId('tab-builtin')).toHaveTextContent('tools.type.builtIn') - expect(screen.getByTestId('tab-api')).toHaveTextContent('tools.type.custom') - expect(screen.getByTestId('tab-workflow')).toHaveTextContent('tools.type.workflow') - expect(screen.getByTestId('tab-mcp')).toHaveTextContent('MCP') + renderProviderList() + expect(screen.getByText('tools.type.builtIn')).toBeInTheDocument() + expect(screen.getByText('tools.type.custom')).toBeInTheDocument() + expect(screen.getByText('tools.type.workflow')).toBeInTheDocument() + expect(screen.getByText('MCP')).toBeInTheDocument() }) it('switches tab when clicked', () => { - render(<ProviderList />) - fireEvent.click(screen.getByTestId('tab-api')) - expect(mockSetActiveTab).toHaveBeenCalledWith('api') + renderProviderList() + fireEvent.click(screen.getByText('tools.type.custom')) + expect(screen.getByTestId('custom-create-card')).toBeInTheDocument() + }) + + it('resets current provider when switching to a different tab', () => { + renderProviderList() + fireEvent.click(screen.getByTestId('card-google-search')) + expect(screen.getByTestId('provider-detail')).toBeInTheDocument() + fireEvent.click(screen.getByText('tools.type.custom')) + expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument() + }) + + it('does not reset provider when clicking the already active tab', () => { + renderProviderList() + fireEvent.click(screen.getByTestId('card-google-search')) + expect(screen.getByTestId('provider-detail')).toBeInTheDocument() + fireEvent.click(screen.getByText('tools.type.builtIn')) + expect(screen.getByTestId('provider-detail')).toBeInTheDocument() }) }) describe('Filtering', () => { it('shows only builtin collections by default', () => { - render(<ProviderList />) + renderProviderList() expect(screen.getByTestId('card-google-search')).toBeInTheDocument() + expect(screen.getByTestId('card-weather-tool')).toBeInTheDocument() expect(screen.queryByTestId('card-my-api')).not.toBeInTheDocument() }) it('filters by search keyword', () => { - render(<ProviderList />) + renderProviderList() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'nonexistent' } }) expect(screen.queryByTestId('card-google-search')).not.toBeInTheDocument() }) + it('filters by search keyword matching label', () => { + renderProviderList() + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'Google' } }) + expect(screen.getByTestId('card-google-search')).toBeInTheDocument() + expect(screen.queryByTestId('card-weather-tool')).not.toBeInTheDocument() + }) + + it('filters collections by tag', () => { + renderProviderList() + fireEvent.click(screen.getByTestId('add-filter')) + expect(screen.getByTestId('card-google-search')).toBeInTheDocument() + expect(screen.queryByTestId('card-weather-tool')).not.toBeInTheDocument() + expect(screen.queryByTestId('card-plugin-tool')).not.toBeInTheDocument() + }) + + it('restores all collections when tag filter is cleared', () => { + renderProviderList() + fireEvent.click(screen.getByTestId('add-filter')) + expect(screen.queryByTestId('card-weather-tool')).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('clear-filter')) + expect(screen.getByTestId('card-google-search')).toBeInTheDocument() + expect(screen.getByTestId('card-weather-tool')).toBeInTheDocument() + }) + + it('clears search with clear button', () => { + renderProviderList() + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'Google' } }) + expect(screen.queryByTestId('card-weather-tool')).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('input-clear')) + expect(screen.getByTestId('card-weather-tool')).toBeInTheDocument() + }) + it('shows label filter for non-MCP tabs', () => { - render(<ProviderList />) + renderProviderList() expect(screen.getByTestId('label-filter')).toBeInTheDocument() }) + it('hides label filter for MCP tab', () => { + renderProviderList({ category: 'mcp' }) + expect(screen.queryByTestId('label-filter')).not.toBeInTheDocument() + }) + it('renders search input', () => { - render(<ProviderList />) + renderProviderList() expect(screen.getByRole('textbox')).toBeInTheDocument() }) }) describe('Custom Tab', () => { it('shows custom create card when on api tab', () => { - mockActiveTab = 'api' - render(<ProviderList />) + renderProviderList({ category: 'api' }) 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 + it('shows workflow collections', () => { + renderProviderList({ category: 'workflow' }) expect(screen.getByTestId('card-wf-tool')).toBeInTheDocument() }) + + it('shows empty state when no workflow collections exist', () => { + mockCollectionData = createDefaultCollections().filter(c => c.type !== 'workflow') + renderProviderList({ category: 'workflow' }) + expect(screen.getByTestId('workflow-empty')).toBeInTheDocument() + }) + }) + + describe('Builtin Tab Empty State', () => { + it('shows empty component when no builtin collections', () => { + mockCollectionData = createDefaultCollections().filter(c => c.type !== 'builtin') + renderProviderList() + expect(screen.getByTestId('empty')).toBeInTheDocument() + }) + + it('renders collection that has no labels property', () => { + mockCollectionData = [{ + id: 'no-labels', + name: 'no-label-tool', + author: 'Dify', + description: { en_US: 'Tool', zh_Hans: 'ć·„ć…·' }, + icon: 'icon', + label: { en_US: 'No Label Tool', zh_Hans: 'æ— æ ‡ç­Ÿć·„ć…·' }, + type: 'builtin', + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + }] as unknown as ReturnType<typeof createDefaultCollections> + renderProviderList() + expect(screen.getByTestId('card-no-label-tool')).toBeInTheDocument() + }) }) describe('MCP Tab', () => { it('renders MCPList component', () => { - mockActiveTab = 'mcp' - render(<ProviderList />) + renderProviderList({ category: 'mcp' }) expect(screen.getByTestId('mcp-list')).toBeInTheDocument() }) }) describe('Provider Detail', () => { it('opens provider detail when a non-plugin collection is clicked', () => { - render(<ProviderList />) + renderProviderList() 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 />) + renderProviderList() 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() }) }) + + describe('Plugin Detail Panel', () => { + it('shows plugin detail panel when collection with plugin_id is selected', () => { + mockCheckedInstalledData = { + plugins: [{ id: 'org/plugin-tool', name: 'Plugin Tool' }], + } + renderProviderList() + expect(screen.queryByTestId('plugin-detail-panel')).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('card-plugin-tool')) + expect(screen.getByTestId('plugin-detail-panel')).toBeInTheDocument() + }) + + it('calls invalidateInstalledPluginList on plugin update', () => { + mockCheckedInstalledData = { + plugins: [{ id: 'org/plugin-tool', name: 'Plugin Tool' }], + } + renderProviderList() + fireEvent.click(screen.getByTestId('card-plugin-tool')) + fireEvent.click(screen.getByTestId('plugin-update')) + expect(mockInvalidateInstalledPluginList).toHaveBeenCalled() + }) + + it('clears current provider on plugin panel close', () => { + mockCheckedInstalledData = { + plugins: [{ id: 'org/plugin-tool', name: 'Plugin Tool' }], + } + renderProviderList() + fireEvent.click(screen.getByTestId('card-plugin-tool')) + expect(screen.getByTestId('plugin-detail-panel')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('plugin-close')) + expect(screen.queryByTestId('plugin-detail-panel')).not.toBeInTheDocument() + }) + }) + + describe('Marketplace', () => { + it('shows marketplace when enable_marketplace is true and on builtin tab', () => { + mockEnableMarketplace = true + renderProviderList() + expect(screen.getByTestId('marketplace')).toBeInTheDocument() + }) + + it('does not show marketplace when enable_marketplace is false', () => { + renderProviderList() + expect(screen.queryByTestId('marketplace')).not.toBeInTheDocument() + }) + + it('scrolls to marketplace panel on arrow click', () => { + mockEnableMarketplace = true + renderProviderList() + fireEvent.click(screen.getByTestId('marketplace-arrow')) + expect(Element.prototype.scrollTo).toHaveBeenCalled() + }) + }) + + describe('Scroll Handling', () => { + it('delegates scroll events to marketplace handleScroll', () => { + mockEnableMarketplace = true + const { container } = renderProviderList() + const scrollContainer = container.querySelector('.overflow-y-auto') as HTMLDivElement + fireEvent.scroll(scrollContainer) + expect(mockHandleScroll).toHaveBeenCalled() + }) + + it('updates marketplace arrow visibility on scroll', () => { + mockEnableMarketplace = true + renderProviderList() + expect(screen.getByTestId('marketplace-arrow')).toHaveTextContent('arrow-visible') + const scrollContainer = document.querySelector('.overflow-y-auto') as HTMLDivElement + fireEvent.scroll(scrollContainer) + expect(screen.getByTestId('marketplace-arrow')).toHaveTextContent('arrow-hidden') + }) + }) }) diff --git a/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx index bc170ad2cd..43ce810217 100644 --- a/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/no-unnecessary-use-prefix */ import type { ReactNode } from 'react' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' @@ -9,7 +8,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' import MCPServiceCard from '../mcp-service-card' -// Mock MCPServerModal vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({ default: ({ show, onHide }: { show: boolean, onHide: () => void }) => { if (!show) @@ -22,21 +20,6 @@ vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({ }, })) -// Mock Confirm -vi.mock('@/app/components/base/confirm', () => ({ - default: ({ isShow, onConfirm, onCancel }: { isShow: boolean, onConfirm: () => void, onCancel: () => void }) => { - if (!isShow) - return null - return ( - <div data-testid="confirm-dialog"> - <button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button> - <button data-testid="cancel-btn" onClick={onCancel}>Cancel</button> - </div> - ) - }, -})) - -// Mutable mock handlers for hook const mockHandleStatusChange = vi.fn().mockResolvedValue({ activated: true }) const mockHandleServerModalHide = vi.fn().mockReturnValue({ shouldDeactivate: false }) const mockHandleGenCode = vi.fn() @@ -44,7 +27,6 @@ const mockOpenConfirmDelete = vi.fn() const mockCloseConfirmDelete = vi.fn() const mockOpenServerModal = vi.fn() -// Type for mock hook state type MockHookState = { genLoading: boolean isLoading: boolean @@ -68,8 +50,7 @@ type MockHookState = { latestParams: Array<unknown> } -// Default hook state factory - creates fresh state for each test -const createDefaultHookState = (): MockHookState => ({ +const createDefaultHookState = (overrides: Partial<MockHookState> = {}): MockHookState => ({ genLoading: false, isLoading: false, serverPublished: true, @@ -90,12 +71,11 @@ const createDefaultHookState = (): MockHookState => ({ showConfirmDelete: false, showMCPServerModal: false, latestParams: [], + ...overrides, }) -// Mutable hook state - modify this in tests to change component behavior let mockHookState = createDefaultHookState() -// Mock the hook - uses mockHookState which can be modified per test vi.mock('../hooks/use-mcp-service-card', () => ({ useMCPServiceCardState: () => ({ ...mockHookState, @@ -111,11 +91,7 @@ vi.mock('../hooks/use-mcp-service-card', () => ({ describe('MCPServiceCard', () => { const createWrapper = () => { const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, + defaultOptions: { queries: { retry: false } }, }) return ({ children }: { children: ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children) @@ -129,10 +105,7 @@ describe('MCPServiceCard', () => { } as AppDetailResponse & Partial<AppSSO>) beforeEach(() => { - // Reset hook state to defaults before each test mockHookState = createDefaultHookState() - - // Reset all mock function call history mockHandleStatusChange.mockClear().mockResolvedValue({ activated: true }) mockHandleServerModalHide.mockClear().mockReturnValue({ shouldDeactivate: false }) mockHandleGenCode.mockClear() @@ -142,300 +115,142 @@ describe('MCPServiceCard', () => { }) describe('Rendering', () => { - it('should render without crashing', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + it('should render title, status indicator, and switch', () => { + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should render the MCP icon', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // The Mcp icon should be present in the component - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should render status indicator', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Status indicator shows running or disable expect(screen.getByText(/appOverview.overview.status/)).toBeInTheDocument() - }) - - it('should render switch toggle', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - expect(screen.getByRole('switch')).toBeInTheDocument() }) - it('should render in minimal or full state based on server status', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + it('should render edit button in full state', () => { + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) - // Component renders either in minimal or full state + const editBtn = screen.getByRole('button', { name: /tools\.mcp\.server\.edit/i }) + expect(editBtn).toBeInTheDocument() + }) + + it('should return null when isLoading is true', () => { + mockHookState = createDefaultHookState({ isLoading: true }) + + const { container } = render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) + expect(container.firstChild).toBeNull() + }) + + it('should render content when isLoading is false', () => { + mockHookState = createDefaultHookState({ isLoading: false }) + + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() }) - - it('should render edit button', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Edit or add description button - const editOrAddButton = screen.queryByText('tools.mcp.server.edit') || screen.queryByText('tools.mcp.server.addDescription') - expect(editOrAddButton).toBeInTheDocument() - }) - }) - - describe('Status Indicator', () => { - it('should show running status when server is activated', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // The status text should be present - expect(screen.getByText(/appOverview.overview.status/)).toBeInTheDocument() - }) - }) - - describe('Server URL Display', () => { - it('should display title in both minimal and full state', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Title should always be displayed - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - }) - - describe('Trigger Mode Disabled', () => { - it('should apply opacity when triggerModeDisabled is true', () => { - const appInfo = createMockAppInfo() - render( - <MCPServiceCard appInfo={appInfo} triggerModeDisabled={true} />, - { wrapper: createWrapper() }, - ) - - // Component should have reduced opacity class - const container = document.querySelector('.opacity-60') - expect(container).toBeInTheDocument() - }) - - it('should not apply opacity when triggerModeDisabled is false', () => { - const appInfo = createMockAppInfo() - render( - <MCPServiceCard appInfo={appInfo} triggerModeDisabled={false} />, - { wrapper: createWrapper() }, - ) - - // Component should not have reduced opacity class on the main content - const opacityElements = document.querySelectorAll('.opacity-60') - // The opacity-60 should not be present when not disabled - expect(opacityElements.length).toBe(0) - }) - - it('should render overlay when triggerModeDisabled is true', () => { - const appInfo = createMockAppInfo() - render( - <MCPServiceCard appInfo={appInfo} triggerModeDisabled={true} />, - { wrapper: createWrapper() }, - ) - - // Overlay should have cursor-not-allowed - const overlay = document.querySelector('.cursor-not-allowed') - expect(overlay).toBeInTheDocument() - }) }) describe('Different App Modes', () => { - it('should render for chat app', () => { - const appInfo = createMockAppInfo(AppModeEnum.CHAT) - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should render for workflow app', () => { - const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW) - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should render for advanced chat app', () => { - const appInfo = createMockAppInfo(AppModeEnum.ADVANCED_CHAT) - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should render for completion app', () => { - const appInfo = createMockAppInfo(AppModeEnum.COMPLETION) - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should render for agent chat app', () => { - const appInfo = createMockAppInfo(AppModeEnum.AGENT_CHAT) - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + it.each([ + AppModeEnum.CHAT, + AppModeEnum.WORKFLOW, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.COMPLETION, + AppModeEnum.AGENT_CHAT, + ])('should render for %s app mode', (mode) => { + render(<MCPServiceCard appInfo={createMockAppInfo(mode)} />, { wrapper: createWrapper() }) expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() + expect(screen.getByRole('switch')).toBeInTheDocument() }) }) - describe('User Interactions', () => { - it('should toggle switch', async () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + describe('Trigger Mode Disabled', () => { + it('should show cursor-not-allowed overlay when triggerModeDisabled is true', () => { + const { container } = render( + <MCPServiceCard appInfo={createMockAppInfo()} triggerModeDisabled={true} />, + { wrapper: createWrapper() }, + ) - const switchElement = screen.getByRole('switch') - fireEvent.click(switchElement) + const overlay = container.querySelector('.cursor-not-allowed[aria-hidden="true"]') + expect(overlay).toBeInTheDocument() + }) + + it('should not show cursor-not-allowed overlay when triggerModeDisabled is false', () => { + const { container } = render( + <MCPServiceCard appInfo={createMockAppInfo()} triggerModeDisabled={false} />, + { wrapper: createWrapper() }, + ) + + const overlay = container.querySelector('.cursor-not-allowed[aria-hidden="true"]') + expect(overlay).toBeNull() + }) + }) + + describe('Switch Toggle', () => { + it('should call handleStatusChange with false when turning off an active server', async () => { + mockHookState = createDefaultHookState({ serverActivated: true }) + mockHandleStatusChange.mockResolvedValue({ activated: false }) + + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) + fireEvent.click(screen.getByRole('switch')) - // Switch should be interactive await waitFor(() => { - expect(switchElement).toBeInTheDocument() + expect(mockHandleStatusChange).toHaveBeenCalledWith(false) }) }) - it('should have switch button available', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + it('should call handleStatusChange with true when turning on an inactive server', async () => { + mockHookState = createDefaultHookState({ serverActivated: false }) + mockHandleStatusChange.mockResolvedValue({ activated: true }) + + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) + fireEvent.click(screen.getByRole('switch')) + + await waitFor(() => { + expect(mockHandleStatusChange).toHaveBeenCalledWith(true) + }) + }) + + it('should show disabled styling when toggleDisabled is true', () => { + mockHookState = createDefaultHookState({ toggleDisabled: true }) + + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) - // The switch is a button role element const switchElement = screen.getByRole('switch') - expect(switchElement).toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should accept triggerModeMessage prop', () => { - const appInfo = createMockAppInfo() - const message = 'Custom trigger mode message' - render( - <MCPServiceCard - appInfo={appInfo} - triggerModeDisabled={true} - triggerModeMessage={message} - />, - { wrapper: createWrapper() }, - ) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should handle empty triggerModeMessage', () => { - const appInfo = createMockAppInfo() - render( - <MCPServiceCard - appInfo={appInfo} - triggerModeDisabled={true} - triggerModeMessage="" - />, - { wrapper: createWrapper() }, - ) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should handle ReactNode as triggerModeMessage', () => { - const appInfo = createMockAppInfo() - const message = <span data-testid="custom-message">Custom message</span> - render( - <MCPServiceCard - appInfo={appInfo} - triggerModeDisabled={true} - triggerModeMessage={message} - />, - { wrapper: createWrapper() }, - ) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle minimal app info', () => { - const minimalAppInfo = { - id: 'minimal-app', - name: 'Minimal', - mode: AppModeEnum.CHAT, - api_base_url: 'https://api.example.com/v1', - } as AppDetailResponse & Partial<AppSSO> - - render(<MCPServiceCard appInfo={minimalAppInfo} />, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should handle app info with special characters in name', () => { - const appInfo = { - id: 'app-special', - name: 'Test App <script>alert("xss")</script>', - mode: AppModeEnum.CHAT, - api_base_url: 'https://api.example.com/v1', - } as AppDetailResponse & Partial<AppSSO> - - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() + expect(switchElement.className).toContain('!cursor-not-allowed') + expect(switchElement.className).toContain('!opacity-50') }) }) describe('Server Not Published', () => { beforeEach(() => { - // Modify hookState to simulate unpublished server - mockHookState = { - ...createDefaultHookState(), + mockHookState = createDefaultHookState({ serverPublished: false, serverActivated: false, serverURL: '***********', detail: undefined, isMinimalState: true, - } + }) }) - it('should show add description button when server is not published', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + it('should render in minimal state without edit button', () => { + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) - const buttons = screen.queryAllByRole('button') - const addDescButton = buttons.find(btn => - btn.textContent?.includes('tools.mcp.server.addDescription'), - ) - expect(addDescButton || screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should show masked URL when server is not published', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // In minimal/unpublished state, the URL should be masked or not shown expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /tools\.mcp\.server\.edit/i })).not.toBeInTheDocument() }) it('should open modal when enabling unpublished server', async () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + mockHandleStatusChange.mockResolvedValue({ activated: false }) - const switchElement = screen.getByRole('switch') - fireEvent.click(switchElement) + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) + fireEvent.click(screen.getByRole('switch')) await waitFor(() => { - const modal = screen.queryByTestId('mcp-server-modal') - if (modal) - expect(modal).toBeInTheDocument() + expect(mockHandleStatusChange).toHaveBeenCalledWith(true) }) }) }) describe('Inactive Server', () => { beforeEach(() => { - // Modify hookState to simulate inactive server - mockHookState = { - ...createDefaultHookState(), + mockHookState = createDefaultHookState({ serverActivated: false, detail: { id: 'server-123', @@ -444,423 +259,36 @@ describe('MCPServiceCard', () => { description: 'Test server', parameters: {}, }, - } + }) }) - it('should show disabled status when server is inactive', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + it('should show disabled status indicator', () => { + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) expect(screen.getByText(/appOverview.overview.status/)).toBeInTheDocument() }) - it('should toggle switch when server is inactive', async () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + it('should allow toggling switch when server is inactive but published', async () => { + mockHandleStatusChange.mockResolvedValue({ activated: true }) - const switchElement = screen.getByRole('switch') - expect(switchElement).toBeInTheDocument() + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) + fireEvent.click(screen.getByRole('switch')) - fireEvent.click(switchElement) - - // Switch should be interactive when server is inactive but published await waitFor(() => { - expect(switchElement).toBeInTheDocument() + expect(mockHandleStatusChange).toHaveBeenCalledWith(true) }) }) }) - describe('Non-Manager User', () => { - beforeEach(() => { - // Modify hookState to simulate non-manager user - mockHookState = { - ...createDefaultHookState(), - isCurrentWorkspaceManager: false, - } - }) - - it('should not show regenerate button for non-manager', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Regenerate button should not be visible - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - }) - - describe('Non-Editor User', () => { - it('should show disabled styling for non-editor switch', () => { - mockHookState = { - ...createDefaultHookState(), - toggleDisabled: true, - } - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - const switchElement = screen.getByRole('switch') - // Switch uses CSS classes for disabled state, not disabled attribute - expect(switchElement.className).toContain('!cursor-not-allowed') - expect(switchElement.className).toContain('!opacity-50') - }) - }) - describe('Confirm Regenerate Dialog', () => { - it('should open confirm dialog and regenerate on confirm', async () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Find and click regenerate button - const regenerateButtons = document.querySelectorAll('.cursor-pointer') - const regenerateBtn = Array.from(regenerateButtons).find(btn => - btn.querySelector('svg.h-4.w-4'), - ) - - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - - await waitFor(() => { - const confirmDialog = screen.queryByTestId('confirm-dialog') - if (confirmDialog) { - expect(confirmDialog).toBeInTheDocument() - const confirmBtn = screen.getByTestId('confirm-btn') - fireEvent.click(confirmBtn) - } - }) - } - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should close confirm dialog on cancel', async () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Find and click regenerate button - const regenerateButtons = document.querySelectorAll('.cursor-pointer') - const regenerateBtn = Array.from(regenerateButtons).find(btn => - btn.querySelector('svg.h-4.w-4'), - ) - - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - - await waitFor(() => { - const confirmDialog = screen.queryByTestId('confirm-dialog') - if (confirmDialog) { - expect(confirmDialog).toBeInTheDocument() - const cancelBtn = screen.getByTestId('cancel-btn') - fireEvent.click(cancelBtn) - } - }) - } - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - }) - - describe('MCP Server Modal', () => { - it('should open and close server modal', async () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Find edit button - const buttons = screen.queryAllByRole('button') - const editButton = buttons.find(btn => - btn.textContent?.includes('tools.mcp.server.edit') - || btn.textContent?.includes('tools.mcp.server.addDescription'), - ) - - if (editButton) { - fireEvent.click(editButton) - - await waitFor(() => { - const modal = screen.queryByTestId('mcp-server-modal') - if (modal) { - expect(modal).toBeInTheDocument() - const closeBtn = screen.getByTestId('close-modal-btn') - fireEvent.click(closeBtn) - } - }) - } - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should deactivate switch when modal closes without previous activation', async () => { - // Simulate unpublished server state - mockHookState = { - ...createDefaultHookState(), - serverPublished: false, - serverActivated: false, - detail: undefined, - showMCPServerModal: true, - } - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Modal should be visible - const modal = screen.getByTestId('mcp-server-modal') - expect(modal).toBeInTheDocument() - - const closeBtn = screen.getByTestId('close-modal-btn') - fireEvent.click(closeBtn) - - await waitFor(() => { - expect(mockHandleServerModalHide).toHaveBeenCalled() - }) - - // Switch should be off after closing modal without activation - const switchElement = screen.getByRole('switch') - expect(switchElement).toBeInTheDocument() - }) - }) - - describe('Unpublished App', () => { - it('should show minimal state for unpublished app', () => { - mockHookState = { - ...createDefaultHookState(), - appUnpublished: true, - isMinimalState: true, - } - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should show disabled styling for unpublished app switch', () => { - mockHookState = { - ...createDefaultHookState(), - appUnpublished: true, - toggleDisabled: true, - } - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - const switchElement = screen.getByRole('switch') - // Switch uses CSS classes for disabled state - expect(switchElement.className).toContain('!cursor-not-allowed') - expect(switchElement.className).toContain('!opacity-50') - }) - }) - - describe('Workflow App Without Start Node', () => { - it('should show minimal state for workflow without start node', () => { - mockHookState = { - ...createDefaultHookState(), - missingStartNode: true, - isMinimalState: true, - } - - const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW) - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - - it('should show disabled styling for workflow without start node', () => { - mockHookState = { - ...createDefaultHookState(), - missingStartNode: true, - toggleDisabled: true, - } - - const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW) - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - const switchElement = screen.getByRole('switch') - // Switch uses CSS classes for disabled state - expect(switchElement.className).toContain('!cursor-not-allowed') - expect(switchElement.className).toContain('!opacity-50') - }) - }) - - describe('Loading State', () => { - it('should return null when isLoading is true', () => { - mockHookState = { - ...createDefaultHookState(), - isLoading: true, - } - - const appInfo = createMockAppInfo() - const { container } = render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Component returns null when loading - expect(container.firstChild).toBeNull() - }) - - it('should render content when isLoading is false', () => { - mockHookState = { - ...createDefaultHookState(), - isLoading: false, - } - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - }) - - describe('TriggerModeOverlay', () => { - it('should show overlay without tooltip when triggerModeMessage is empty', () => { - const appInfo = createMockAppInfo() - render( - <MCPServiceCard appInfo={appInfo} triggerModeDisabled={true} triggerModeMessage="" />, - { wrapper: createWrapper() }, - ) - - const overlay = document.querySelector('.cursor-not-allowed') - expect(overlay).toBeInTheDocument() - }) - - it('should show overlay with tooltip when triggerModeMessage is provided', () => { - const appInfo = createMockAppInfo() - render( - <MCPServiceCard appInfo={appInfo} triggerModeDisabled={true} triggerModeMessage="Custom message" />, - { wrapper: createWrapper() }, - ) - - const overlay = document.querySelector('.cursor-not-allowed') - expect(overlay).toBeInTheDocument() - }) - }) - - describe('onChangeStatus Handler', () => { - it('should call handleStatusChange with false when turning off', async () => { - // Start with server activated - mockHookState = { - ...createDefaultHookState(), - serverActivated: true, - } - mockHandleStatusChange.mockResolvedValue({ activated: false }) - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - const switchElement = screen.getByRole('switch') - - // Click to turn off - this will trigger onChangeStatus(false) - fireEvent.click(switchElement) - - await waitFor(() => { - expect(mockHandleStatusChange).toHaveBeenCalledWith(false) - }) - }) - - it('should call handleStatusChange with true when turning on', async () => { - // Start with server deactivated - mockHookState = { - ...createDefaultHookState(), - serverActivated: false, - } - mockHandleStatusChange.mockResolvedValue({ activated: true }) - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - const switchElement = screen.getByRole('switch') - - // Click to turn on - this will trigger onChangeStatus(true) - fireEvent.click(switchElement) - - await waitFor(() => { - expect(mockHandleStatusChange).toHaveBeenCalledWith(true) - }) - }) - - it('should set local activated to false when handleStatusChange returns activated: false and state is true', async () => { - // Simulate unpublished server scenario where enabling opens modal - mockHookState = { - ...createDefaultHookState(), - serverActivated: false, - serverPublished: false, - } - // Handler returns activated: false (modal opened instead) - mockHandleStatusChange.mockResolvedValue({ activated: false }) - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - const switchElement = screen.getByRole('switch') - - // Click to turn on - fireEvent.click(switchElement) - - await waitFor(() => { - expect(mockHandleStatusChange).toHaveBeenCalledWith(true) - }) - - // The local state should be set to false because result.activated is false - expect(switchElement).toBeInTheDocument() - }) - }) - - describe('onServerModalHide Handler', () => { - it('should deactivate when handleServerModalHide returns shouldDeactivate: true', async () => { - // Set up to show modal - mockHookState = { - ...createDefaultHookState(), - showMCPServerModal: true, - serverActivated: false, // Server was not activated - } - mockHandleServerModalHide.mockReturnValue({ shouldDeactivate: true }) - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Close the modal - const closeBtn = screen.getByTestId('close-modal-btn') - fireEvent.click(closeBtn) - - await waitFor(() => { - expect(mockHandleServerModalHide).toHaveBeenCalled() - }) - }) - - it('should not deactivate when handleServerModalHide returns shouldDeactivate: false', async () => { - mockHookState = { - ...createDefaultHookState(), - showMCPServerModal: true, - serverActivated: true, // Server was already activated - } - mockHandleServerModalHide.mockReturnValue({ shouldDeactivate: false }) - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Close the modal - const closeBtn = screen.getByTestId('close-modal-btn') - fireEvent.click(closeBtn) - - await waitFor(() => { - expect(mockHandleServerModalHide).toHaveBeenCalled() - }) - }) - }) - - describe('onConfirmRegenerate Handler', () => { it('should call handleGenCode and closeConfirmDelete when confirm is clicked', async () => { - // Set up to show confirm dialog - mockHookState = { - ...createDefaultHookState(), - showConfirmDelete: true, - } + mockHookState = createDefaultHookState({ showConfirmDelete: true }) - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) - // Confirm dialog should be visible - const confirmDialog = screen.getByTestId('confirm-dialog') - expect(confirmDialog).toBeInTheDocument() + expect(screen.getByText('appOverview.overview.appInfo.regenerate')).toBeInTheDocument() - // Click confirm button - const confirmBtn = screen.getByTestId('confirm-btn') - fireEvent.click(confirmBtn) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { expect(mockHandleGenCode).toHaveBeenCalled() @@ -869,173 +297,142 @@ describe('MCPServiceCard', () => { }) it('should call closeConfirmDelete when cancel is clicked', async () => { - mockHookState = { - ...createDefaultHookState(), - showConfirmDelete: true, - } + mockHookState = createDefaultHookState({ showConfirmDelete: true }) - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) - // Click cancel button - const cancelBtn = screen.getByTestId('cancel-btn') - fireEvent.click(cancelBtn) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { expect(mockCloseConfirmDelete).toHaveBeenCalled() + expect(mockHandleGenCode).not.toHaveBeenCalled() }) }) }) - describe('getTooltipContent Function', () => { - it('should show publish tip when app is unpublished', () => { - // Modify hookState to simulate unpublished app - mockHookState = { - ...createDefaultHookState(), - appUnpublished: true, - toggleDisabled: true, - isMinimalState: true, - } + describe('MCP Server Modal', () => { + it('should render modal when showMCPServerModal is true', () => { + mockHookState = createDefaultHookState({ showMCPServerModal: true }) - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) - // Tooltip should contain publish tip - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() + expect(screen.getByTestId('mcp-server-modal')).toBeInTheDocument() }) - it('should show missing start node tooltip for workflow without start node', () => { - // Modify hookState to simulate missing start node - mockHookState = { - ...createDefaultHookState(), - missingStartNode: true, - toggleDisabled: true, - isMinimalState: true, - } + it('should call handleServerModalHide when modal is closed', async () => { + mockHookState = createDefaultHookState({ + showMCPServerModal: true, + serverActivated: false, + }) - const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW) - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) - // The tooltip with learn more link should be available - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('close-modal-btn')) + + await waitFor(() => { + expect(mockHandleServerModalHide).toHaveBeenCalled() + }) }) - it('should return triggerModeMessage when trigger mode is disabled', () => { - const appInfo = createMockAppInfo() - render( - <MCPServiceCard - appInfo={appInfo} - triggerModeDisabled={true} - triggerModeMessage="Test trigger message" - />, - { wrapper: createWrapper() }, - ) + it('should open modal via edit button click', async () => { + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() + const editBtn = screen.getByRole('button', { name: /tools\.mcp\.server\.edit/i }) + fireEvent.click(editBtn) + + expect(mockOpenServerModal).toHaveBeenCalled() }) }) - describe('State Synchronization', () => { - it('should sync activated state when serverActivated changes', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + describe('Unpublished App', () => { + it('should show minimal state and disabled switch', () => { + mockHookState = createDefaultHookState({ + appUnpublished: true, + isMinimalState: true, + toggleDisabled: true, + }) + + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) + + const switchElement = screen.getByRole('switch') + expect(switchElement.className).toContain('!cursor-not-allowed') + expect(switchElement.className).toContain('!opacity-50') + }) + }) + + describe('Workflow Without Start Node', () => { + it('should show minimal state with disabled switch', () => { + mockHookState = createDefaultHookState({ + missingStartNode: true, + isMinimalState: true, + toggleDisabled: true, + }) + + render(<MCPServiceCard appInfo={createMockAppInfo(AppModeEnum.WORKFLOW)} />, { wrapper: createWrapper() }) + + const switchElement = screen.getByRole('switch') + expect(switchElement.className).toContain('!cursor-not-allowed') + expect(switchElement.className).toContain('!opacity-50') + }) + }) + + describe('onChangeStatus edge case', () => { + it('should clear pending status when handleStatusChange returns activated: false for an enable action', async () => { + mockHookState = createDefaultHookState({ + serverActivated: false, + serverPublished: false, + }) + mockHandleStatusChange.mockResolvedValue({ activated: false }) + + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) + fireEvent.click(screen.getByRole('switch')) + + await waitFor(() => { + expect(mockHandleStatusChange).toHaveBeenCalledWith(true) + }) - // Initial state expect(screen.getByRole('switch')).toBeInTheDocument() }) }) - describe('Accessibility', () => { - it('should have accessible switch', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + describe('onServerModalHide', () => { + it('should call handleServerModalHide with shouldDeactivate: true', async () => { + mockHookState = createDefaultHookState({ + showMCPServerModal: true, + serverActivated: false, + }) + mockHandleServerModalHide.mockReturnValue({ shouldDeactivate: true }) - const switchElement = screen.getByRole('switch') - expect(switchElement).toBeInTheDocument() + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) + fireEvent.click(screen.getByTestId('close-modal-btn')) + + await waitFor(() => { + expect(mockHandleServerModalHide).toHaveBeenCalled() + }) }) - it('should have accessible interactive elements', () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) + it('should call handleServerModalHide with shouldDeactivate: false', async () => { + mockHookState = createDefaultHookState({ + showMCPServerModal: true, + serverActivated: true, + }) + mockHandleServerModalHide.mockReturnValue({ shouldDeactivate: false }) + + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) + fireEvent.click(screen.getByTestId('close-modal-btn')) + + await waitFor(() => { + expect(mockHandleServerModalHide).toHaveBeenCalled() + }) + }) + }) + + describe('Accessibility', () => { + it('should have an accessible switch with button type', () => { + render(<MCPServiceCard appInfo={createMockAppInfo()} />, { wrapper: createWrapper() }) - // The switch element with button type is an interactive element const switchElement = screen.getByRole('switch') - expect(switchElement).toBeInTheDocument() expect(switchElement).toHaveAttribute('type', 'button') }) }) - - describe('Server URL Regeneration', () => { - it('should open confirm dialog when regenerate is clicked', async () => { - // Mock to show regenerate button - vi.doMock('@/service/use-tools', async () => { - return { - useUpdateMCPServer: () => ({ - mutateAsync: vi.fn().mockResolvedValue({}), - }), - useRefreshMCPServerCode: () => ({ - mutateAsync: vi.fn().mockResolvedValue({}), - isPending: false, - }), - useMCPServerDetail: () => ({ - data: { - id: 'server-123', - status: 'active', - server_code: 'abc123', - description: 'Test server', - parameters: {}, - }, - }), - useInvalidateMCPServerDetail: () => vi.fn(), - } - }) - - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Find the regenerate button and click it - const regenerateButtons = document.querySelectorAll('.cursor-pointer') - const regenerateBtn = Array.from(regenerateButtons).find(btn => - btn.querySelector('svg'), - ) - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - - // Wait for confirm dialog to appear - await waitFor(() => { - const confirmTitle = screen.queryByText('appOverview.overview.appInfo.regenerate') - if (confirmTitle) - expect(confirmTitle).toBeInTheDocument() - }, { timeout: 100 }) - } - }) - }) - - describe('Edit Button', () => { - it('should open MCP server modal when edit button is clicked', async () => { - const appInfo = createMockAppInfo() - render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() }) - - // Find button with edit text - use queryAllByRole since buttons may not exist - const buttons = screen.queryAllByRole('button') - const editButton = buttons.find(btn => - btn.textContent?.includes('tools.mcp.server.edit') - || btn.textContent?.includes('tools.mcp.server.addDescription'), - ) - - if (editButton) { - fireEvent.click(editButton) - - // Modal should open - check for any modal indicator - await waitFor(() => { - // If modal opens, we should see modal content - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - } - else { - // In minimal state, no edit button is shown - this is expected behavior - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - } - }) - }) }) diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx index 48fd4ef29d..ed6136f3c5 100644 --- a/web/app/components/tools/provider-list.tsx +++ b/web/app/components/tools/provider-list.tsx @@ -18,25 +18,11 @@ import { useGlobalPublicStore } from '@/context/global-public-context' import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useAllToolProviders } from '@/service/use-tools' import { cn } from '@/utils/classnames' -import { ToolTypeEnum } from '../workflow/block-selector/types' import Marketplace from './marketplace' import { useMarketplace } from './marketplace/hooks' import MCPList from './mcp' +import { getToolType } from './utils' -const getToolType = (type: string) => { - switch (type) { - case 'builtin': - return ToolTypeEnum.BuiltIn - case 'api': - return ToolTypeEnum.Custom - case 'workflow': - return ToolTypeEnum.Workflow - case 'mcp': - return ToolTypeEnum.MCP - default: - return ToolTypeEnum.BuiltIn - } -} const ProviderList = () => { // const searchParams = useSearchParams() // searchParams.get('category') === 'workflow' diff --git a/web/app/components/tools/utils/index.ts b/web/app/components/tools/utils/index.ts index ced9ca1879..4db5ae9081 100644 --- a/web/app/components/tools/utils/index.ts +++ b/web/app/components/tools/utils/index.ts @@ -1,6 +1,22 @@ import type { ThoughtItem } from '@/app/components/base/chat/chat/type' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { VisionFile } from '@/types/app' +import { ToolTypeEnum } from '../../workflow/block-selector/types' + +export const getToolType = (type: string) => { + switch (type) { + case 'builtin': + return ToolTypeEnum.BuiltIn + case 'api': + return ToolTypeEnum.Custom + case 'workflow': + return ToolTypeEnum.Workflow + case 'mcp': + return ToolTypeEnum.MCP + default: + return ToolTypeEnum.BuiltIn + } +} export const sortAgentSorts = (list: ThoughtItem[]) => { if (!list) diff --git a/web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/chat-variable-trigger.spec.tsx similarity index 95% rename from web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.spec.tsx rename to web/app/components/workflow-app/components/workflow-header/__tests__/chat-variable-trigger.spec.tsx index e8efa2b50a..44549a815b 100644 --- a/web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/chat-variable-trigger.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import ChatVariableTrigger from './chat-variable-trigger' +import ChatVariableTrigger from '../chat-variable-trigger' const mockUseNodesReadOnly = vi.fn() const mockUseIsChatMode = vi.fn() @@ -8,7 +8,7 @@ vi.mock('@/app/components/workflow/hooks', () => ({ useNodesReadOnly: () => mockUseNodesReadOnly(), })) -vi.mock('../../hooks', () => ({ +vi.mock('../../../hooks', () => ({ useIsChatMode: () => mockUseIsChatMode(), })) diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx similarity index 99% rename from web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx rename to web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index 724a39837b..4a7fd1275f 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -7,7 +7,7 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { ToastContext } from '@/app/components/base/toast' import { Plan } from '@/app/components/billing/type' import { BlockEnum, InputVarType } from '@/app/components/workflow/types' -import FeaturesTrigger from './features-trigger' +import FeaturesTrigger from '../features-trigger' const mockUseIsChatMode = vi.fn() const mockUseTheme = vi.fn() diff --git a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/workflow-app/components/workflow-header/index.spec.tsx rename to web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx index eb3148498f..54b1ee410f 100644 --- a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/index.spec.tsx @@ -4,7 +4,7 @@ import type { App } from '@/types/app' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { useStore as useAppStore } from '@/app/components/app/store' import { AppModeEnum } from '@/types/app' -import WorkflowHeader from './index' +import WorkflowHeader from '../index' const mockResetWorkflowVersionHistory = vi.fn() diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx rename to web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx index 63d0344275..ca627f9679 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { BlockEnum } from '@/app/components/workflow/types' -import WorkflowOnboardingModal from './index' +import WorkflowOnboardingModal from '../index' // Mock Modal component vi.mock('@/app/components/base/modal', () => ({ @@ -33,14 +33,9 @@ vi.mock('@/app/components/base/modal', () => ({ }, })) -// Mock useDocLink hook -vi.mock('@/context/i18n', () => ({ - useDocLink: () => (path: string) => `https://docs.example.com${path}`, -})) - // Mock StartNodeSelectionPanel (using real component would be better for integration, // but for this test we'll mock to control behavior) -vi.mock('./start-node-selection-panel', () => ({ +vi.mock('../start-node-selection-panel', () => ({ default: function MockStartNodeSelectionPanel({ onSelectUserInput, onSelectTrigger, diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-option.spec.tsx similarity index 99% rename from web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.spec.tsx rename to web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-option.spec.tsx index 9c77ebfdfe..04c223499a 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-option.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import StartNodeOption from './start-node-option' +import StartNodeOption from '../start-node-option' describe('StartNodeOption', () => { const mockOnClick = vi.fn() diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx similarity index 98% rename from web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx rename to web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx index 43d8c1a8e1..b2496f8714 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { BlockEnum } from '@/app/components/workflow/types' -import StartNodeSelectionPanel from './start-node-selection-panel' +import StartNodeSelectionPanel from '../start-node-selection-panel' // Mock NodeSelector component vi.mock('@/app/components/workflow/block-selector', () => ({ @@ -11,7 +11,12 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ onOpenChange, onSelect, trigger, - }: any) { + }: { + open: boolean + onOpenChange: (open: boolean) => void + onSelect: (type: BlockEnum) => void + trigger: (() => React.ReactNode) | React.ReactNode + }) { // trigger is a function that returns a React element const triggerElement = typeof trigger === 'function' ? trigger() : trigger From f3f56f03e3feb303dbd8f4a0e50cef8e17f6c896 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:48:08 +0800 Subject: [PATCH 15/18] chore(deps): bump qs from 6.14.1 to 6.14.2 in /web (#32290) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/package.json | 2 +- web/pnpm-lock.yaml | 48 +++++++++++++++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/web/package.json b/web/package.json index 9e75d88cd2..24fdaafb60 100644 --- a/web/package.json +++ b/web/package.json @@ -128,7 +128,7 @@ "nuqs": "2.8.6", "pinyin-pro": "3.27.0", "qrcode.react": "4.2.0", - "qs": "6.14.1", + "qs": "6.14.2", "react": "19.2.4", "react-18-input-autosize": "3.0.0", "react-dom": "19.2.4", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ae618e857e..73abcd2101 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -264,8 +264,8 @@ importers: specifier: 4.2.0 version: 4.2.0(react@19.2.4) qs: - specifier: 6.14.1 - version: 6.14.1 + specifier: 6.14.2 + version: 6.14.2 react: specifier: 19.2.4 version: 19.2.4 @@ -825,6 +825,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -853,6 +858,10 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -942,10 +951,6 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@discoveryjs/json-ext@0.5.7': - resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} - engines: {node: '>=10.0.0'} - '@egoist/tailwindcss-icons@1.9.2': resolution: {integrity: sha512-I6XsSykmhu2cASg5Hp/ICLsJ/K/1aXPaSKjgbWaNp2xYnb4We/arWMmkhhV+9CglOFCUbqx0A3mM2kWV32ZIhw==} peerDependencies: @@ -4484,6 +4489,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -6396,8 +6405,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - qs@6.14.1: - resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -8051,6 +8060,10 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 @@ -8086,6 +8099,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} '@braintree/sanitize-url@7.1.1': {} @@ -8205,8 +8223,6 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@discoveryjs/json-ext@0.5.7': {} - '@egoist/tailwindcss-icons@1.9.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@iconify/utils': 3.1.0 @@ -11042,7 +11058,7 @@ snapshots: '@vue/compiler-sfc@3.5.27': dependencies: - '@babel/parser': 7.28.6 + '@babel/parser': 7.29.0 '@vue/compiler-core': 3.5.27 '@vue/compiler-dom': 3.5.27 '@vue/compiler-ssr': 3.5.27 @@ -11991,6 +12007,12 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + optional: true + entities@4.5.0: {} entities@6.0.1: {} @@ -14461,7 +14483,7 @@ snapshots: dependencies: react: 19.2.4 - qs@6.14.1: + qs@6.14.2: dependencies: side-channel: '@nolyfill/side-channel@1.0.44' @@ -15818,7 +15840,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.15.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.19.0 es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 From 84d090db33c6cd0d7c6f3139b03eb3a5e4fb6797 Mon Sep 17 00:00:00 2001 From: Poojan <poojan@infocusp.com> Date: Fri, 13 Feb 2026 08:44:14 +0530 Subject: [PATCH 16/18] test: add unit tests for base components-part-1 (#32154) --- .../base/content-dialog/index.spec.tsx | 59 +++++ web/app/components/base/dialog/index.spec.tsx | 138 +++++++++++ .../base/fullscreen-modal/index.spec.tsx | 214 ++++++++++++++++++ .../base/new-audio-button/index.spec.tsx | 205 +++++++++++++++++ .../base/notion-connector/index.spec.tsx | 49 ++++ .../components/base/skeleton/index.spec.tsx | 83 +++++++ web/app/components/base/slider/index.spec.tsx | 77 +++++++ web/app/components/base/sort/index.spec.tsx | 141 ++++++++++++ web/app/components/base/switch/index.spec.tsx | 84 +++++++ web/app/components/base/tag/index.spec.tsx | 104 +++++++++ 10 files changed, 1154 insertions(+) create mode 100644 web/app/components/base/content-dialog/index.spec.tsx create mode 100644 web/app/components/base/dialog/index.spec.tsx create mode 100644 web/app/components/base/fullscreen-modal/index.spec.tsx create mode 100644 web/app/components/base/new-audio-button/index.spec.tsx create mode 100644 web/app/components/base/notion-connector/index.spec.tsx create mode 100644 web/app/components/base/skeleton/index.spec.tsx create mode 100644 web/app/components/base/slider/index.spec.tsx create mode 100644 web/app/components/base/sort/index.spec.tsx create mode 100644 web/app/components/base/switch/index.spec.tsx create mode 100644 web/app/components/base/tag/index.spec.tsx diff --git a/web/app/components/base/content-dialog/index.spec.tsx b/web/app/components/base/content-dialog/index.spec.tsx new file mode 100644 index 0000000000..a047fdf062 --- /dev/null +++ b/web/app/components/base/content-dialog/index.spec.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ContentDialog from './index' + +describe('ContentDialog', () => { + it('renders children when show is true', async () => { + render( + <ContentDialog show={true}> + <div>Dialog body</div> + </ContentDialog>, + ) + + await screen.findByText('Dialog body') + expect(screen.getByText('Dialog body')).toBeInTheDocument() + + const backdrop = document.querySelector('.bg-app-detail-overlay-bg') + expect(backdrop).toBeTruthy() + }) + + it('does not render children when show is false', () => { + render( + <ContentDialog show={false}> + <div>Hidden content</div> + </ContentDialog>, + ) + + expect(screen.queryByText('Hidden content')).toBeNull() + expect(document.querySelector('.bg-app-detail-overlay-bg')).toBeNull() + }) + + it('calls onClose when backdrop is clicked', async () => { + const onClose = vi.fn() + render( + <ContentDialog show={true} onClose={onClose}> + <div>Body</div> + </ContentDialog>, + ) + + const user = userEvent.setup() + const backdrop = document.querySelector('.bg-app-detail-overlay-bg') as HTMLElement | null + expect(backdrop).toBeTruthy() + + await user.click(backdrop!) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('applies provided className to the content panel', () => { + render( + <ContentDialog show={true} className="my-panel-class"> + <div>Panel content</div> + </ContentDialog>, + ) + + const contentPanel = document.querySelector('.bg-app-detail-bg') as HTMLElement | null + expect(contentPanel).toBeTruthy() + expect(contentPanel?.className).toContain('my-panel-class') + expect(screen.getByText('Panel content')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/dialog/index.spec.tsx b/web/app/components/base/dialog/index.spec.tsx new file mode 100644 index 0000000000..c58724595f --- /dev/null +++ b/web/app/components/base/dialog/index.spec.tsx @@ -0,0 +1,138 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import CustomDialog from './index' + +describe('CustomDialog Component', () => { + const setup = () => userEvent.setup() + + it('should render children and title when show is true', async () => { + render( + <CustomDialog show={true} title="Modal Title"> + <div data-testid="dialog-content">Main Content</div> + </CustomDialog>, + ) + + const title = await screen.findByText('Modal Title') + const content = screen.getByTestId('dialog-content') + + expect(title).toBeInTheDocument() + expect(content).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should not render anything when show is false', async () => { + render( + <CustomDialog show={false} title="Hidden Title"> + <div>Content</div> + </CustomDialog>, + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('Hidden Title')).not.toBeInTheDocument() + }) + + it('should apply the correct semantic tag to title using titleAs', async () => { + render( + <CustomDialog show={true} title="Semantic Title" titleAs="h1"> + Content + </CustomDialog>, + ) + + const title = await screen.findByRole('heading', { level: 1 }) + expect(title).toHaveTextContent('Semantic Title') + }) + + it('should render the footer only when the prop is provided', async () => { + const { rerender } = render( + <CustomDialog show={true}>Content</CustomDialog>, + ) + + await screen.findByRole('dialog') + expect(screen.queryByText('Footer Content')).not.toBeInTheDocument() + + rerender( + <CustomDialog show={true} footer={<div data-testid="footer-node">Footer Content</div>}> + Content + </CustomDialog>, + ) + + expect(await screen.findByTestId('footer-node')).toBeInTheDocument() + }) + + it('should call onClose when Escape key is pressed', async () => { + const user = setup() + const onCloseMock = vi.fn() + + render( + <CustomDialog show={true} onClose={onCloseMock}> + Content + </CustomDialog>, + ) + + await screen.findByRole('dialog') + + await act(async () => { + await user.keyboard('{Escape}') + }) + + expect(onCloseMock).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when the backdrop is clicked', async () => { + const user = setup() + const onCloseMock = vi.fn() + + render( + <CustomDialog show={true} onClose={onCloseMock}> + Content + </CustomDialog>, + ) + + await screen.findByRole('dialog') + + const backdrop = document.querySelector('.bg-background-overlay-backdrop') + expect(backdrop).toBeInTheDocument() + + await act(async () => { + await user.click(backdrop!) + }) + + expect(onCloseMock).toHaveBeenCalledTimes(1) + }) + + it('should apply custom class names to internal elements', async () => { + render( + <CustomDialog + show={true} + title="Title" + className="custom-panel-container" + titleClassName="custom-title-style" + bodyClassName="custom-body-style" + footer="Footer" + footerClassName="custom-footer-style" + > + <div data-testid="content">Content</div> + </CustomDialog>, + ) + + await screen.findByRole('dialog') + + expect(document.querySelector('.custom-panel-container')).toBeInTheDocument() + expect(document.querySelector('.custom-title-style')).toBeInTheDocument() + expect(document.querySelector('.custom-body-style')).toBeInTheDocument() + expect(document.querySelector('.custom-footer-style')).toBeInTheDocument() + }) + + it('should maintain accessibility attributes (aria-modal)', async () => { + render( + <CustomDialog show={true} title="Accessibility Test"> + <button>Focusable Item</button> + </CustomDialog>, + ) + + const dialog = await screen.findByRole('dialog') + // Headless UI should automatically set aria-modal="true" + expect(dialog).toHaveAttribute('aria-modal', 'true') + }) +}) diff --git a/web/app/components/base/fullscreen-modal/index.spec.tsx b/web/app/components/base/fullscreen-modal/index.spec.tsx new file mode 100644 index 0000000000..cf1484fc63 --- /dev/null +++ b/web/app/components/base/fullscreen-modal/index.spec.tsx @@ -0,0 +1,214 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import FullScreenModal from './index' + +describe('FullScreenModal Component', () => { + it('should not render anything when open is false', () => { + render( + <FullScreenModal open={false}> + <div data-testid="modal-content">Content</div> + </FullScreenModal>, + ) + expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument() + }) + + it('should render content when open is true', async () => { + render( + <FullScreenModal open={true}> + <div data-testid="modal-content">Content</div> + </FullScreenModal>, + ) + expect(await screen.findByTestId('modal-content')).toBeInTheDocument() + }) + + it('should not crash when provided with title and description props', async () => { + await act(async () => { + render( + <FullScreenModal + open={true} + title="My Title" + description="My Description" + > + Content + </FullScreenModal>, + ) + }) + }) + + describe('Props Handling', () => { + it('should apply wrapperClassName to the dialog root', async () => { + render( + <FullScreenModal + open={true} + wrapperClassName="custom-wrapper-class" + > + Content + </FullScreenModal>, + ) + + await screen.findByRole('dialog') + const element = document.querySelector('.custom-wrapper-class') + expect(element).toBeInTheDocument() + expect(element).toHaveClass('modal-dialog') + }) + + it('should apply className to the inner panel', async () => { + await act(async () => { + render( + <FullScreenModal + open={true} + className="custom-panel-class" + > + Content + </FullScreenModal>, + ) + }) + const panel = document.querySelector('.custom-panel-class') + expect(panel).toBeInTheDocument() + expect(panel).toHaveClass('h-full') + }) + + it('should handle overflowVisible prop', async () => { + const { rerender } = await act(async () => { + return render( + <FullScreenModal + open={true} + overflowVisible={true} + className="target-panel" + > + Content + </FullScreenModal>, + ) + }) + let panel = document.querySelector('.target-panel') + expect(panel).toHaveClass('overflow-visible') + expect(panel).not.toHaveClass('overflow-hidden') + + await act(async () => { + rerender( + <FullScreenModal + open={true} + overflowVisible={false} + className="target-panel" + > + Content + </FullScreenModal>, + ) + }) + panel = document.querySelector('.target-panel') + expect(panel).toHaveClass('overflow-hidden') + expect(panel).not.toHaveClass('overflow-visible') + }) + + it('should render close button when closable is true', async () => { + await act(async () => { + render( + <FullScreenModal open={true} closable={true}> + Content + </FullScreenModal>, + ) + }) + const closeButton = document.querySelector('.bg-components-button-tertiary-bg') + expect(closeButton).toBeInTheDocument() + }) + + it('should not render close button when closable is false', async () => { + await act(async () => { + render( + <FullScreenModal open={true} closable={false}> + Content + </FullScreenModal>, + ) + }) + const closeButton = document.querySelector('.bg-components-button-tertiary-bg') + expect(closeButton).not.toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('should call onClose when close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + <FullScreenModal open={true} closable={true} onClose={onClose}> + Content + </FullScreenModal>, + ) + + const closeBtn = document.querySelector('.bg-components-button-tertiary-bg') + expect(closeBtn).toBeInTheDocument() + + await user.click(closeBtn!) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when clicking the backdrop', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + <FullScreenModal open={true} onClose={onClose}> + <div data-testid="inner">Content</div> + </FullScreenModal>, + ) + + const dialog = document.querySelector('.modal-dialog') + if (dialog) { + await user.click(dialog) + expect(onClose).toHaveBeenCalled() + } + else { + throw new Error('Dialog root not found') + } + }) + + it('should call onClose when Escape key is pressed', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + <FullScreenModal open={true} onClose={onClose}> + Content + </FullScreenModal>, + ) + + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalled() + }) + + it('should not call onClose when clicking inside the content', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + <FullScreenModal open={true} onClose={onClose}> + <div className="bg-background-default-subtle"> + <button>Action</button> + </div> + </FullScreenModal>, + ) + + const innerButton = screen.getByRole('button', { name: 'Action' }) + await user.click(innerButton) + expect(onClose).not.toHaveBeenCalled() + + const contentPanel = document.querySelector('.bg-background-default-subtle') + await act(async () => { + fireEvent.click(contentPanel!) + }) + expect(onClose).not.toHaveBeenCalled() + }) + }) + + describe('Default Props', () => { + it('should not throw if onClose is not provided', async () => { + const user = userEvent.setup() + render(<FullScreenModal open={true} closable={true}>Content</FullScreenModal>) + + const closeButton = document.querySelector('.bg-components-button-tertiary-bg') + await user.click(closeButton!) + }) + }) +}) diff --git a/web/app/components/base/new-audio-button/index.spec.tsx b/web/app/components/base/new-audio-button/index.spec.tsx new file mode 100644 index 0000000000..a30b06535a --- /dev/null +++ b/web/app/components/base/new-audio-button/index.spec.tsx @@ -0,0 +1,205 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import i18next from 'i18next' +import { useParams, usePathname } from 'next/navigation' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import AudioBtn from './index' + +const mockPlayAudio = vi.fn() +const mockPauseAudio = vi.fn() +const mockGetAudioPlayer = vi.fn() + +vi.mock('next/navigation', () => ({ + useParams: vi.fn(), + usePathname: vi.fn(), +})) + +vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ + AudioPlayerManager: { + getInstance: vi.fn(() => ({ + getAudioPlayer: mockGetAudioPlayer, + })), + }, +})) + +describe('AudioBtn', () => { + const getButton = () => screen.getByRole('button') + + const hoverAndCheckTooltip = async (expectedText: string) => { + const button = getButton() + await userEvent.hover(button) + expect(await screen.findByText(expectedText)).toBeInTheDocument() + } + + const getAudioCallback = () => { + const lastCall = mockGetAudioPlayer.mock.calls[mockGetAudioPlayer.mock.calls.length - 1] + const callback = lastCall?.find((arg: unknown) => typeof arg === 'function') as ((event: string) => void) | undefined + if (!callback) + throw new Error('Audio callback not found - ensure mockGetAudioPlayer was called with a callback argument') + return callback + } + + beforeAll(() => { + i18next.init({}) + }) + + beforeEach(() => { + vi.clearAllMocks() + mockGetAudioPlayer.mockReturnValue({ + playAudio: mockPlayAudio, + pauseAudio: mockPauseAudio, + }) + ; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({}) + ; (usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/') + }) + + describe('URL Routing', () => { + it('should generate public URL when token is present', async () => { + ; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({ token: 'test-token' }) + + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + expect(mockGetAudioPlayer.mock.calls[0][0]).toBe('/text-to-audio') + expect(mockGetAudioPlayer.mock.calls[0][1]).toBe(true) + }) + + it('should generate app URL when appId is present', async () => { + ; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({ appId: '123' }) + ; (usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/apps/123/chat') + + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + expect(mockGetAudioPlayer.mock.calls[0][0]).toBe('/apps/123/text-to-audio') + expect(mockGetAudioPlayer.mock.calls[0][1]).toBe(false) + }) + + it('should generate installed app URL correctly', async () => { + ; (useParams as ReturnType<typeof vi.fn>).mockReturnValue({ appId: '456' }) + ; (usePathname as ReturnType<typeof vi.fn>).mockReturnValue('/explore/installed/app') + + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + expect(mockGetAudioPlayer.mock.calls[0][0]).toBe('/installed-apps/456/text-to-audio') + }) + }) + + describe('State Management', () => { + it('should start in initial state', async () => { + render(<AudioBtn value="test" />) + + await hoverAndCheckTooltip('play') + expect(getButton()).toHaveClass('action-btn') + expect(getButton()).not.toBeDisabled() + }) + + it('should transition to playing state', async () => { + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('play') + }) + + await hoverAndCheckTooltip('playing') + expect(getButton()).toHaveClass('action-btn-active') + }) + + it('should transition to ended state', async () => { + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('play') + }) + act(() => { + getAudioCallback()('ended') + }) + + await hoverAndCheckTooltip('play') + expect(getButton()).not.toHaveClass('action-btn-active') + }) + + it('should handle paused event', async () => { + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('play') + }) + act(() => { + getAudioCallback()('paused') + }) + + await hoverAndCheckTooltip('play') + }) + + it('should handle error event', async () => { + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('error') + }) + + await hoverAndCheckTooltip('play') + }) + + it('should handle loaded event', async () => { + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('loaded') + }) + + await hoverAndCheckTooltip('loading') + }) + }) + + describe('Play/Pause', () => { + it('should call playAudio when clicked', async () => { + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + await waitFor(() => expect(mockPlayAudio).toHaveBeenCalled()) + }) + + it('should call pauseAudio when clicked while playing', async () => { + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('play') + }) + + await userEvent.click(getButton()) + await waitFor(() => expect(mockPauseAudio).toHaveBeenCalled()) + }) + + it('should disable button when loading', async () => { + render(<AudioBtn value="test" />) + await userEvent.click(getButton()) + + await waitFor(() => expect(getButton()).toBeDisabled()) + }) + }) + + describe('Props', () => { + it('should pass props to audio player', async () => { + render(<AudioBtn value="hello" id="msg-1" voice="en-US" />) + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + const call = mockGetAudioPlayer.mock.calls[0] + expect(call[2]).toBe('msg-1') + expect(call[3]).toBe('hello') + expect(call[4]).toBe('en-US') + }) + }) +}) diff --git a/web/app/components/base/notion-connector/index.spec.tsx b/web/app/components/base/notion-connector/index.spec.tsx new file mode 100644 index 0000000000..7ee799d002 --- /dev/null +++ b/web/app/components/base/notion-connector/index.spec.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import NotionConnector from './index' + +describe('NotionConnector', () => { + it('should render the layout and actual sub-components (Icons & Button)', () => { + const { container } = render(<NotionConnector onSetting={vi.fn()} />) + + // Verify Title & Tip translations + expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.notionSyncTip')).toBeInTheDocument() + + const notionWrapper = container.querySelector('.h-12.w-12') + const dotsWrapper = container.querySelector('.system-md-semibold') + + expect(notionWrapper?.querySelector('svg')).toBeInTheDocument() + expect(dotsWrapper?.querySelector('svg')).toBeInTheDocument() + + const button = screen.getByRole('button', { + name: /datasetcreation.stepone.connect/i, + }) + + expect(button).toBeInTheDocument() + expect(button).toHaveClass('btn', 'btn-primary') + }) + + it('should trigger the onSetting callback when the real button is clicked', async () => { + const onSetting = vi.fn() + const user = userEvent.setup() + render(<NotionConnector onSetting={onSetting} />) + + const button = screen.getByRole('button', { + name: /datasetcreation.stepone.connect/i, + }) + + await user.click(button) + + expect(onSetting).toHaveBeenCalledTimes(1) + }) + + it('should maintain the correct visual hierarchy classes', () => { + const { container } = render(<NotionConnector onSetting={vi.fn()} />) + + // Verify the outer container has the specific workflow-process-bg + const mainContainer = container.firstChild + expect(mainContainer).toHaveClass('bg-workflow-process-bg', 'rounded-2xl', 'p-6') + }) +}) diff --git a/web/app/components/base/skeleton/index.spec.tsx b/web/app/components/base/skeleton/index.spec.tsx new file mode 100644 index 0000000000..8f0d9a6837 --- /dev/null +++ b/web/app/components/base/skeleton/index.spec.tsx @@ -0,0 +1,83 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { + SkeletonContainer, + SkeletonPoint, + SkeletonRectangle, + SkeletonRow, +} from './index' + +describe('Skeleton Components', () => { + describe('Individual Components', () => { + it('should forward attributes and render children in SkeletonContainer', () => { + render( + <SkeletonContainer data-testid="container" className="custom-container"> + <span>Content</span> + </SkeletonContainer>, + ) + const element = screen.getByTestId('container') + expect(element).toHaveClass('flex', 'flex-col', 'custom-container') + expect(screen.getByText('Content')).toBeInTheDocument() + }) + + it('should forward attributes and render children in SkeletonRow', () => { + render( + <SkeletonRow data-testid="row" className="custom-row"> + <span>Row Content</span> + </SkeletonRow>, + ) + const element = screen.getByTestId('row') + expect(element).toHaveClass('flex', 'items-center', 'custom-row') + expect(screen.getByText('Row Content')).toBeInTheDocument() + }) + + it('should apply base skeleton styles to SkeletonRectangle', () => { + render(<SkeletonRectangle data-testid="rect" className="w-10" />) + const element = screen.getByTestId('rect') + expect(element).toHaveClass('h-2', 'bg-text-quaternary', 'opacity-20', 'w-10') + }) + + it('should render the separator character correctly in SkeletonPoint', () => { + render(<SkeletonPoint data-testid="point" />) + const element = screen.getByTestId('point') + expect(element).toHaveTextContent('·') + expect(element).toHaveClass('text-text-quaternary') + }) + }) + + describe('Composition & Layout', () => { + it('should render a full skeleton structure accurately', () => { + const { container } = render( + <SkeletonContainer className="main-wrapper"> + <SkeletonRow> + <SkeletonRectangle className="rect-1" /> + <SkeletonPoint /> + <SkeletonRectangle className="rect-2" /> + </SkeletonRow> + </SkeletonContainer>, + ) + + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('main-wrapper') + + expect(container.querySelector('.rect-1')).toBeInTheDocument() + expect(container.querySelector('.rect-2')).toBeInTheDocument() + + const row = container.querySelector('.flex.items-center') + expect(row).toContainElement(container.querySelector('.rect-1') as HTMLElement) + expect(row).toHaveTextContent('·') + }) + }) + + it('should handle rest props like event listeners', async () => { + const onClick = vi.fn() + const user = userEvent.setup() + render(<SkeletonRectangle onClick={onClick} data-testid="clickable" />) + + const element = screen.getByTestId('clickable') + + await user.click(element) + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/base/slider/index.spec.tsx b/web/app/components/base/slider/index.spec.tsx new file mode 100644 index 0000000000..c9ebabd63e --- /dev/null +++ b/web/app/components/base/slider/index.spec.tsx @@ -0,0 +1,77 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import Slider from './index' + +describe('Slider Component', () => { + it('should render with correct default ARIA limits and current value', () => { + render(<Slider value={50} onChange={vi.fn()} />) + + const slider = screen.getByRole('slider') + expect(slider).toHaveAttribute('aria-valuemin', '0') + expect(slider).toHaveAttribute('aria-valuemax', '100') + expect(slider).toHaveAttribute('aria-valuenow', '50') + }) + + it('should apply custom min, max, and step values', () => { + render(<Slider value={10} min={5} max={20} step={5} onChange={vi.fn()} />) + + const slider = screen.getByRole('slider') + expect(slider).toHaveAttribute('aria-valuemin', '5') + expect(slider).toHaveAttribute('aria-valuemax', '20') + expect(slider).toHaveAttribute('aria-valuenow', '10') + }) + + it('should default to 0 if the value prop is NaN', () => { + render(<Slider value={Number.NaN} onChange={vi.fn()} />) + + const slider = screen.getByRole('slider') + expect(slider).toHaveAttribute('aria-valuenow', '0') + }) + + it('should call onChange when arrow keys are pressed', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render(<Slider value={20} onChange={onChange} />) + + const slider = screen.getByRole('slider') + + await act(async () => { + slider.focus() + await user.keyboard('{ArrowRight}') + }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(21, 0) + }) + + it('should not trigger onChange when disabled', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render(<Slider value={20} onChange={onChange} disabled />) + + const slider = screen.getByRole('slider') + + expect(slider).toHaveAttribute('aria-disabled', 'true') + + await act(async () => { + slider.focus() + await user.keyboard('{ArrowRight}') + }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should apply custom class names', () => { + render( + <Slider value={10} onChange={vi.fn()} className="outer-test" thumbClassName="thumb-test" />, + ) + + const sliderWrapper = screen.getByRole('slider').closest('.outer-test') + expect(sliderWrapper).toBeInTheDocument() + + const thumb = screen.getByRole('slider') + expect(thumb).toHaveClass('thumb-test') + }) +}) diff --git a/web/app/components/base/sort/index.spec.tsx b/web/app/components/base/sort/index.spec.tsx new file mode 100644 index 0000000000..92ea2b44f9 --- /dev/null +++ b/web/app/components/base/sort/index.spec.tsx @@ -0,0 +1,141 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { describe, expect, it, vi } from 'vitest' +import Sort from './index' + +const mockItems = [ + { value: 'created_at', name: 'Date Created' }, + { value: 'name', name: 'Name' }, + { value: 'status', name: 'Status' }, +] + +describe('Sort component — real portal integration', () => { + const setup = (props = {}) => { + const onSelect = vi.fn() + const user = userEvent.setup() + const { container, rerender } = render( + <Sort value="created_at" items={mockItems} onSelect={onSelect} order="" {...props} />, + ) + + // helper: returns a non-null HTMLElement or throws with a clear message + const getTriggerWrapper = (): HTMLElement => { + const labelNode = screen.getByText('appLog.filter.sortBy') + // try to find a reasonable wrapper element; prefer '.block' but fallback to any ancestor div + const wrapper = labelNode.closest('.block') ?? labelNode.closest('div') + if (!wrapper) + throw new Error('Trigger wrapper element not found for "Sort by" label') + return wrapper as HTMLElement + } + + // helper: returns right-side sort button element + const getSortButton = (): HTMLElement => { + const btn = container.querySelector('.rounded-r-lg') + if (!btn) + throw new Error('Sort button (rounded-r-lg) not found in rendered container') + return btn as HTMLElement + } + + return { user, onSelect, rerender, getTriggerWrapper, getSortButton } + } + + it('renders and shows selected item label and sort icon', () => { + const { getSortButton } = setup({ order: '' }) + + expect(screen.getByText('Date Created')).toBeInTheDocument() + + const sortButton = getSortButton() + expect(sortButton).toBeInstanceOf(HTMLElement) + expect(sortButton.querySelector('svg')).toBeInTheDocument() + }) + + it('opens and closes the tooltip (portal mounts to document.body)', async () => { + const { user, getTriggerWrapper } = setup() + + await user.click(getTriggerWrapper()) + const tooltip = await screen.findByRole('tooltip') + expect(tooltip).toBeInTheDocument() + expect(document.body.contains(tooltip)).toBe(true) + + // clicking the trigger again should close it + await user.click(getTriggerWrapper()) + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()) + }) + + it('renders options and calls onSelect with descending prefix when order is "-"', async () => { + const { user, onSelect, getTriggerWrapper } = setup({ order: '-' }) + + await user.click(getTriggerWrapper()) + const tooltip = await screen.findByRole('tooltip') + + mockItems.forEach((item) => { + expect(within(tooltip).getByText(item.name)).toBeInTheDocument() + }) + + await user.click(within(tooltip).getByText('Name')) + expect(onSelect).toHaveBeenCalledWith('-name') + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()) + }) + + it('toggles sorting order: ascending -> descending via right-side button', async () => { + const { user, onSelect, getSortButton } = setup({ order: '', value: 'created_at' }) + await user.click(getSortButton()) + expect(onSelect).toHaveBeenCalledWith('-created_at') + }) + + it('toggles sorting order: descending -> ascending via right-side button', async () => { + const { user, onSelect, getSortButton } = setup({ order: '-', value: 'name' }) + await user.click(getSortButton()) + expect(onSelect).toHaveBeenCalledWith('name') + }) + + it('shows checkmark only for selected item in menu', async () => { + const { user, getTriggerWrapper } = setup({ value: 'status' }) + + await user.click(getTriggerWrapper()) + const tooltip = await screen.findByRole('tooltip') + + const statusRow = within(tooltip).getByText('Status').closest('.flex') + const nameRow = within(tooltip).getByText('Name').closest('.flex') + + if (!statusRow) + throw new Error('Status option row not found in menu') + if (!nameRow) + throw new Error('Name option row not found in menu') + + expect(statusRow.querySelector('svg')).toBeInTheDocument() + expect(nameRow.querySelector('svg')).not.toBeInTheDocument() + }) + + it('shows empty selection label when value is unknown', () => { + setup({ value: 'unknown_value' }) + const label = screen.getByText('appLog.filter.sortBy') + const valueNode = label.nextSibling + if (!valueNode) + throw new Error('Expected a sibling node for the selection text') + expect(String(valueNode.textContent || '').trim()).toBe('') + }) + + it('handles undefined order prop without asserting a literal "undefined" prefix', async () => { + const { user, onSelect, getTriggerWrapper } = setup({ order: undefined }) + + await user.click(getTriggerWrapper()) + const tooltip = await screen.findByRole('tooltip') + + await user.click(within(tooltip).getByText('Name')) + + expect(onSelect).toHaveBeenCalled() + expect(onSelect).toHaveBeenCalledWith(expect.stringMatching(/name$/)) + }) + + it('clicking outside the open menu closes the portal', async () => { + const { user, getTriggerWrapper } = setup() + await user.click(getTriggerWrapper()) + const tooltip = await screen.findByRole('tooltip') + expect(tooltip).toBeInTheDocument() + + // click outside: body click should close the tooltip + await user.click(document.body) + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()) + }) +}) diff --git a/web/app/components/base/switch/index.spec.tsx b/web/app/components/base/switch/index.spec.tsx new file mode 100644 index 0000000000..b434ddd729 --- /dev/null +++ b/web/app/components/base/switch/index.spec.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import Switch from './index' + +describe('Switch', () => { + it('should render in unchecked state by default', () => { + render(<Switch />) + const switchElement = screen.getByRole('switch') + expect(switchElement).toBeInTheDocument() + expect(switchElement).toHaveAttribute('aria-checked', 'false') + }) + + it('should render in checked state when defaultValue is true', () => { + render(<Switch defaultValue={true} />) + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveAttribute('aria-checked', 'true') + }) + + it('should toggle state and call onChange when clicked', async () => { + const onChange = vi.fn() + const user = userEvent.setup() + render(<Switch onChange={onChange} />) + + const switchElement = screen.getByRole('switch') + + await user.click(switchElement) + expect(switchElement).toHaveAttribute('aria-checked', 'true') + expect(onChange).toHaveBeenCalledWith(true) + expect(onChange).toHaveBeenCalledTimes(1) + + await user.click(switchElement) + expect(switchElement).toHaveAttribute('aria-checked', 'false') + expect(onChange).toHaveBeenCalledWith(false) + expect(onChange).toHaveBeenCalledTimes(2) + }) + + it('should not call onChange when disabled', async () => { + const onChange = vi.fn() + const user = userEvent.setup() + render(<Switch disabled onChange={onChange} />) + + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('!cursor-not-allowed', '!opacity-50') + + await user.click(switchElement) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should apply correct size classes', () => { + const { rerender } = render(<Switch size="xs" />) + // We only need to find the element once + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('h-2.5', 'w-3.5', 'rounded-sm') + + rerender(<Switch size="sm" />) + expect(switchElement).toHaveClass('h-3', 'w-5') + + rerender(<Switch size="md" />) + expect(switchElement).toHaveClass('h-4', 'w-7') + + rerender(<Switch size="l" />) + expect(switchElement).toHaveClass('h-5', 'w-9') + + rerender(<Switch size="lg" />) + expect(switchElement).toHaveClass('h-6', 'w-11') + }) + + it('should apply custom className', () => { + render(<Switch className="custom-test-class" />) + expect(screen.getByRole('switch')).toHaveClass('custom-test-class') + }) + + it('should apply correct background colors based on state', async () => { + const user = userEvent.setup() + render(<Switch />) + const switchElement = screen.getByRole('switch') + + expect(switchElement).toHaveClass('bg-components-toggle-bg-unchecked') + + await user.click(switchElement) + expect(switchElement).toHaveClass('bg-components-toggle-bg') + }) +}) diff --git a/web/app/components/base/tag/index.spec.tsx b/web/app/components/base/tag/index.spec.tsx new file mode 100644 index 0000000000..76d2915ba8 --- /dev/null +++ b/web/app/components/base/tag/index.spec.tsx @@ -0,0 +1,104 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Tag from './index' +import '@testing-library/jest-dom/vitest' + +describe('Tag Component', () => { + describe('Rendering', () => { + it('should render with text children', () => { + const { container } = render(<Tag>Hello World</Tag>) + expect(container.firstChild).toHaveTextContent('Hello World') + }) + + it('should render with ReactNode children', () => { + render(<Tag><span data-testid="child">Node</span></Tag>) + expect(screen.getByTestId('child')).toBeInTheDocument() + }) + + it('should always apply base layout classes', () => { + const { container } = render(<Tag>Test</Tag>) + expect(container.firstChild).toHaveClass( + 'inline-flex', + 'shrink-0', + 'items-center', + 'rounded-md', + 'px-2.5', + 'py-px', + 'text-xs', + 'leading-5', + ) + }) + }) + + describe('Color Variants', () => { + it.each([ + { color: 'green', text: 'text-green-800', bg: 'bg-green-100' }, + { color: 'yellow', text: 'text-yellow-800', bg: 'bg-yellow-100' }, + { color: 'red', text: 'text-red-800', bg: 'bg-red-100' }, + { color: 'gray', text: 'text-gray-800', bg: 'bg-gray-100' }, + ])('should apply $color color classes', ({ color, text, bg }) => { + type colorType = 'green' | 'yellow' | 'red' | 'gray' | undefined + const { container } = render(<Tag color={color as colorType}>Test</Tag>) + expect(container.firstChild).toHaveClass(text, bg) + }) + + it('should default to green when no color specified', () => { + const { container } = render(<Tag>Test</Tag>) + expect(container.firstChild).toHaveClass('text-green-800', 'bg-green-100') + }) + + it('should not apply color classes for invalid color', () => { + type colorType = 'green' | 'yellow' | 'red' | 'gray' | undefined + const { container } = render(<Tag color={'invalid' as colorType}>Test</Tag>) + const className = (container.firstChild as HTMLElement)?.className || '' + + expect(className).not.toMatch(/text-(green|yellow|red|gray)-800/) + expect(className).not.toMatch(/bg-(green|yellow|red|gray)-100/) + }) + }) + + describe('Boolean Props', () => { + it('should apply border when bordered is true', () => { + const { container } = render(<Tag bordered>Test</Tag>) + expect(container.firstChild).toHaveClass('border-[1px]') + }) + + it('should not apply border by default', () => { + const { container } = render(<Tag>Test</Tag>) + expect(container.firstChild).not.toHaveClass('border-[1px]') + }) + + it('should hide background when hideBg is true', () => { + const { container } = render(<Tag hideBg>Test</Tag>) + expect(container.firstChild).toHaveClass('bg-transparent') + }) + + it('should apply both bordered and hideBg together', () => { + const { container } = render(<Tag bordered hideBg>Test</Tag>) + expect(container.firstChild).toHaveClass('border-[1px]', 'bg-transparent') + }) + + it('should override color background with hideBg', () => { + const { container } = render(<Tag color="red" hideBg>Test</Tag>) + const tag = container.firstChild + expect(tag).toHaveClass('bg-transparent', 'text-red-800') + }) + }) + + describe('Custom Styling', () => { + it('should merge custom className', () => { + const { container } = render(<Tag className="my-custom-class">Test</Tag>) + expect(container.firstChild).toHaveClass('my-custom-class') + }) + + it('should preserve base classes with custom className', () => { + const { container } = render(<Tag className="my-custom-class">Test</Tag>) + expect(container.firstChild).toHaveClass('inline-flex', 'my-custom-class') + }) + + it('should handle empty className prop', () => { + const { container } = render(<Tag className="">Test</Tag>) + expect(container.firstChild).toBeInTheDocument() + }) + }) +}) From a4e03d6284aff2f64e9240ccd2d25c754cd702d2 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 13 Feb 2026 13:21:09 +0800 Subject: [PATCH 17/18] test: add integration tests for app card operations, list browsing, and create app flows (#32298) Co-authored-by: CodingOnStar <hanxujiang@dify.com> --- .../apps/app-card-operations-flow.test.tsx | 459 +++++++++++++++++ .../apps/app-list-browsing-flow.test.tsx | 439 +++++++++++++++++ web/__tests__/apps/create-app-flow.test.tsx | 465 ++++++++++++++++++ .../apps/{ => __tests__}/app-card.spec.tsx | 57 +-- .../apps/{ => __tests__}/empty.spec.tsx | 3 +- .../apps/{ => __tests__}/footer.spec.tsx | 3 +- .../apps/{ => __tests__}/index.spec.tsx | 12 +- .../apps/{ => __tests__}/list.spec.tsx | 335 ++++--------- .../{ => __tests__}/new-app-card.spec.tsx | 27 +- .../use-apps-query-state.spec.tsx | 17 +- .../{ => __tests__}/use-dsl-drag-drop.spec.ts | 21 +- .../develop/__tests__/code.spec.tsx | 5 +- .../secret-key/__tests__/input-copy.spec.tsx | 5 +- .../__tests__/secret-key-modal.spec.tsx | 6 +- .../explore/try-app/__tests__/index.spec.tsx | 8 +- .../__tests__/version-mismatch-modal.spec.tsx | 4 + .../hooks/__tests__/use-pipeline-init.spec.ts | 2 +- 17 files changed, 1509 insertions(+), 359 deletions(-) create mode 100644 web/__tests__/apps/app-card-operations-flow.test.tsx create mode 100644 web/__tests__/apps/app-list-browsing-flow.test.tsx create mode 100644 web/__tests__/apps/create-app-flow.test.tsx rename web/app/components/apps/{ => __tests__}/app-card.spec.tsx (96%) rename web/app/components/apps/{ => __tests__}/empty.spec.tsx (95%) rename web/app/components/apps/{ => __tests__}/footer.spec.tsx (97%) rename web/app/components/apps/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/apps/{ => __tests__}/list.spec.tsx (67%) rename web/app/components/apps/{ => __tests__}/new-app-card.spec.tsx (87%) rename web/app/components/apps/hooks/{ => __tests__}/use-apps-query-state.spec.tsx (91%) rename web/app/components/apps/hooks/{ => __tests__}/use-dsl-drag-drop.spec.ts (94%) diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx new file mode 100644 index 0000000000..55ad423d88 --- /dev/null +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -0,0 +1,459 @@ +/** + * Integration test: App Card Operations Flow + * + * Tests the end-to-end user flows for app card operations: + * - Editing app info + * - Duplicating an app + * - Deleting an app + * - Exporting app DSL + * - Navigation on card click + * - Access mode icons + */ +import type { App } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppCard from '@/app/components/apps/app-card' +import { AccessMode } from '@/models/access-control' +import { deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' +import { AppModeEnum } from '@/types/app' + +let mockIsCurrentWorkspaceEditor = true +let mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, +} + +const mockRouterPush = vi.fn() +const mockNotify = vi.fn() +const mockOnPlanInfoChanged = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockRouterPush, + }), +})) + +// Mock headless UI Popover so it renders content without transition +vi.mock('@headlessui/react', async () => { + const actual = await vi.importActual<typeof import('@headlessui/react')>('@headlessui/react') + return { + ...actual, + Popover: ({ children, className }: { children: ((bag: { open: boolean }) => React.ReactNode) | React.ReactNode, className?: string }) => ( + <div className={className} data-testid="popover-wrapper"> + {typeof children === 'function' ? children({ open: true }) : children} + </div> + ), + PopoverButton: ({ children, className, ref: _ref, ...rest }: Record<string, unknown>) => ( + <button className={className as string} {...rest}>{children as React.ReactNode}</button> + ), + PopoverPanel: ({ children, className }: { children: ((bag: { close: () => void }) => React.ReactNode) | React.ReactNode, className?: string }) => ( + <div className={className}> + {typeof children === 'function' ? children({ close: vi.fn() }) : children} + </div> + ), + Transition: ({ children }: { children: React.ReactNode }) => <>{children}</>, + } +}) + +vi.mock('next/dynamic', () => ({ + default: (loader: () => Promise<{ default: React.ComponentType }>) => { + let Component: React.ComponentType<Record<string, unknown>> | null = null + loader().then((mod) => { + Component = mod.default as React.ComponentType<Record<string, unknown>> + }).catch(() => {}) + const Wrapper = (props: Record<string, unknown>) => { + if (Component) + return <Component {...props} /> + return null + } + Wrapper.displayName = 'DynamicWrapper' + return Wrapper + }, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => { + const state = { systemFeatures: mockSystemFeatures } + if (typeof selector === 'function') + return selector(state) + return mockSystemFeatures + }, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + onPlanInfoChanged: mockOnPlanInfoChanged, + }), +})) + +// Mock the ToastContext used via useContext from use-context-selector +vi.mock('use-context-selector', async () => { + const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector') + return { + ...actual, + useContext: () => ({ notify: mockNotify }), + } +}) + +vi.mock('@/app/components/base/tag-management/store', () => ({ + useStore: (selector: (state: Record<string, unknown>) => unknown) => { + const state = { + tagList: [], + showTagManagementModal: false, + setTagList: vi.fn(), + setShowTagManagementModal: vi.fn(), + } + return selector(state) + }, +})) + +vi.mock('@/service/tag', () => ({ + fetchTagList: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/service/apps', () => ({ + deleteApp: vi.fn().mockResolvedValue({}), + updateAppInfo: vi.fn().mockResolvedValue({}), + copyApp: vi.fn().mockResolvedValue({ id: 'new-app-id', mode: 'chat' }), + exportAppConfig: vi.fn().mockResolvedValue({ data: 'yaml-content' }), +})) + +vi.mock('@/service/explore', () => ({ + fetchInstalledAppList: vi.fn().mockResolvedValue({ installed_apps: [] }), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }), +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +// Mock modals loaded via next/dynamic +vi.mock('@/app/components/explore/create-app-modal', () => ({ + default: ({ show, onConfirm, onHide, appName }: Record<string, unknown>) => { + if (!show) + return null + return ( + <div data-testid="edit-app-modal"> + <span data-testid="modal-app-name">{appName as string}</span> + <button + data-testid="confirm-edit" + onClick={() => (onConfirm as (data: Record<string, unknown>) => void)({ + name: 'Updated App Name', + icon_type: 'emoji', + icon: 'đŸ”„', + icon_background: '#fff', + description: 'Updated description', + })} + > + Confirm + </button> + <button data-testid="cancel-edit" onClick={onHide as () => void}>Cancel</button> + </div> + ) + }, +})) + +vi.mock('@/app/components/app/duplicate-modal', () => ({ + default: ({ show, onConfirm, onHide }: Record<string, unknown>) => { + if (!show) + return null + return ( + <div data-testid="duplicate-app-modal"> + <button + data-testid="confirm-duplicate" + onClick={() => (onConfirm as (data: Record<string, unknown>) => void)({ + name: 'Copied App', + icon_type: 'emoji', + icon: '📋', + icon_background: '#fff', + })} + > + Confirm Duplicate + </button> + <button data-testid="cancel-duplicate" onClick={onHide as () => void}>Cancel</button> + </div> + ) + }, +})) + +vi.mock('@/app/components/app/switch-app-modal', () => ({ + default: ({ show, onClose, onSuccess }: Record<string, unknown>) => { + if (!show) + return null + return ( + <div data-testid="switch-app-modal"> + <button data-testid="confirm-switch" onClick={onSuccess as () => void}>Confirm Switch</button> + <button data-testid="cancel-switch" onClick={onClose as () => void}>Cancel</button> + </div> + ) + }, +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ isShow, onConfirm, onCancel, title }: Record<string, unknown>) => { + if (!isShow) + return null + return ( + <div data-testid="confirm-delete-modal"> + <span>{title as string}</span> + <button data-testid="confirm-delete" onClick={onConfirm as () => void}>Delete</button> + <button data-testid="cancel-delete" onClick={onCancel as () => void}>Cancel</button> + </div> + ) + }, +})) + +vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ + default: ({ onConfirm, onClose }: Record<string, unknown>) => ( + <div data-testid="dsl-export-confirm-modal"> + <button data-testid="export-include" onClick={() => (onConfirm as (include: boolean) => void)(true)}>Include</button> + <button data-testid="export-close" onClick={onClose as () => void}>Close</button> + </div> + ), +})) + +vi.mock('@/app/components/app/app-access-control', () => ({ + default: ({ onConfirm, onClose }: Record<string, unknown>) => ( + <div data-testid="access-control-modal"> + <button data-testid="confirm-access" onClick={onConfirm as () => void}>Confirm</button> + <button data-testid="cancel-access" onClick={onClose as () => void}>Cancel</button> + </div> + ), +})) + +const createMockApp = (overrides: Partial<App> = {}): App => ({ + id: overrides.id ?? 'app-1', + name: overrides.name ?? 'Test Chat App', + description: overrides.description ?? 'A chat application', + author_name: overrides.author_name ?? 'Test Author', + icon_type: overrides.icon_type ?? 'emoji', + icon: overrides.icon ?? 'đŸ€–', + icon_background: overrides.icon_background ?? '#FFEAD5', + icon_url: overrides.icon_url ?? null, + use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, + mode: overrides.mode ?? AppModeEnum.CHAT, + enable_site: overrides.enable_site ?? true, + enable_api: overrides.enable_api ?? true, + api_rpm: overrides.api_rpm ?? 60, + api_rph: overrides.api_rph ?? 3600, + is_demo: overrides.is_demo ?? false, + model_config: overrides.model_config ?? {} as App['model_config'], + app_model_config: overrides.app_model_config ?? {} as App['app_model_config'], + created_at: overrides.created_at ?? 1700000000, + updated_at: overrides.updated_at ?? 1700001000, + site: overrides.site ?? {} as App['site'], + api_base_url: overrides.api_base_url ?? 'https://api.example.com', + tags: overrides.tags ?? [], + access_mode: overrides.access_mode ?? AccessMode.PUBLIC, + max_active_requests: overrides.max_active_requests ?? null, +}) + +const mockOnRefresh = vi.fn() + +const renderAppCard = (app?: Partial<App>) => { + return render(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />) +} + +describe('App Card Operations Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceEditor = true + mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, + } + }) + + // -- Basic rendering -- + describe('Card Rendering', () => { + it('should render app name and description', () => { + renderAppCard({ name: 'My AI Bot', description: 'An intelligent assistant' }) + + expect(screen.getByText('My AI Bot')).toBeInTheDocument() + expect(screen.getByText('An intelligent assistant')).toBeInTheDocument() + }) + + it('should render author name', () => { + renderAppCard({ author_name: 'John Doe' }) + + expect(screen.getByText('John Doe')).toBeInTheDocument() + }) + + it('should navigate to app config page when card is clicked', () => { + renderAppCard({ id: 'app-123', mode: AppModeEnum.CHAT }) + + const card = screen.getByText('Test Chat App').closest('[class*="cursor-pointer"]') + if (card) + fireEvent.click(card) + + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/configuration') + }) + + it('should navigate to workflow page for workflow apps', () => { + renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' }) + + const card = screen.getByText('WF App').closest('[class*="cursor-pointer"]') + if (card) + fireEvent.click(card) + + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-wf/workflow') + }) + }) + + // -- Delete flow -- + describe('Delete App Flow', () => { + it('should show delete confirmation and call API on confirm', async () => { + renderAppCard({ id: 'app-to-delete', name: 'Deletable App' }) + + // Find and click the more button (popover trigger) + const moreIcons = document.querySelectorAll('svg') + const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + + if (moreFill) { + const btn = moreFill.closest('[class*="cursor-pointer"]') + if (btn) + fireEvent.click(btn) + + await waitFor(() => { + const deleteBtn = screen.queryByText('common.operation.delete') + if (deleteBtn) + fireEvent.click(deleteBtn) + }) + + const confirmBtn = screen.queryByTestId('confirm-delete') + if (confirmBtn) { + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect(deleteApp).toHaveBeenCalledWith('app-to-delete') + }) + } + } + }) + }) + + // -- Edit flow -- + describe('Edit App Flow', () => { + it('should open edit modal and call updateAppInfo on confirm', async () => { + renderAppCard({ id: 'app-edit', name: 'Editable App' }) + + const moreIcons = document.querySelectorAll('svg') + const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + + if (moreFill) { + const btn = moreFill.closest('[class*="cursor-pointer"]') + if (btn) + fireEvent.click(btn) + + await waitFor(() => { + const editBtn = screen.queryByText('app.editApp') + if (editBtn) + fireEvent.click(editBtn) + }) + + const confirmEdit = screen.queryByTestId('confirm-edit') + if (confirmEdit) { + fireEvent.click(confirmEdit) + + await waitFor(() => { + expect(updateAppInfo).toHaveBeenCalledWith( + expect.objectContaining({ + appID: 'app-edit', + name: 'Updated App Name', + }), + ) + }) + } + } + }) + }) + + // -- Export flow -- + describe('Export App Flow', () => { + it('should call exportAppConfig for completion apps', async () => { + renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' }) + + const moreIcons = document.querySelectorAll('svg') + const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + + if (moreFill) { + const btn = moreFill.closest('[class*="cursor-pointer"]') + if (btn) + fireEvent.click(btn) + + await waitFor(() => { + const exportBtn = screen.queryByText('app.export') + if (exportBtn) + fireEvent.click(exportBtn) + }) + + await waitFor(() => { + expect(exportAppConfig).toHaveBeenCalledWith( + expect.objectContaining({ appID: 'app-export' }), + ) + }) + } + }) + }) + + // -- Access mode display -- + describe('Access Mode Display', () => { + it('should not render operations menu for non-editor users', () => { + mockIsCurrentWorkspaceEditor = false + renderAppCard({ name: 'Readonly App' }) + + expect(screen.queryByText('app.editApp')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) + }) + + // -- Switch mode (only for CHAT/COMPLETION) -- + describe('Switch App Mode', () => { + it('should show switch option for chat mode apps', async () => { + renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT }) + + const moreIcons = document.querySelectorAll('svg') + const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + + if (moreFill) { + const btn = moreFill.closest('[class*="cursor-pointer"]') + if (btn) + fireEvent.click(btn) + + await waitFor(() => { + expect(screen.queryByText('app.switch')).toBeInTheDocument() + }) + } + }) + + it('should not show switch option for workflow apps', async () => { + renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' }) + + const moreIcons = document.querySelectorAll('svg') + const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]')) + + if (moreFill) { + const btn = moreFill.closest('[class*="cursor-pointer"]') + if (btn) + fireEvent.click(btn) + + await waitFor(() => { + expect(screen.queryByText('app.switch')).not.toBeInTheDocument() + }) + } + }) + }) +}) diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx new file mode 100644 index 0000000000..32aaddf251 --- /dev/null +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -0,0 +1,439 @@ +/** + * Integration test: App List Browsing Flow + * + * Tests the end-to-end user flow of browsing, filtering, searching, + * and tab switching in the apps list page. + * + * Covers: List, Empty, Footer, AppCardSkeleton, useAppsQueryState, NewAppCard + */ +import type { AppListResponse } from '@/models/app' +import type { App } from '@/types/app' +import { fireEvent, render, screen } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import List from '@/app/components/apps/list' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' + +let mockIsCurrentWorkspaceEditor = true +let mockIsCurrentWorkspaceDatasetOperator = false +let mockIsLoadingCurrentWorkspace = false + +let mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, +} + +let mockPages: AppListResponse[] = [] +let mockIsLoading = false +let mockIsFetching = false +let mockIsFetchingNextPage = false +let mockHasNextPage = false +let mockError: Error | null = null +const mockRefetch = vi.fn() +const mockFetchNextPage = vi.fn() + +let mockShowTagManagementModal = false + +const mockRouterPush = vi.fn() +const mockRouterReplace = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockRouterPush, + replace: mockRouterReplace, + }), + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('next/dynamic', () => ({ + default: (_loader: () => Promise<{ default: React.ComponentType }>) => { + const LazyComponent = (props: Record<string, unknown>) => { + return <div data-testid="dynamic-component" {...props} /> + } + LazyComponent.displayName = 'DynamicComponent' + return LazyComponent + }, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator, + isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => { + const state = { systemFeatures: mockSystemFeatures } + return selector ? selector(state) : state + }, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + onPlanInfoChanged: vi.fn(), + }), +})) + +vi.mock('@/app/components/base/tag-management/store', () => ({ + useStore: (selector: (state: Record<string, unknown>) => unknown) => { + const state = { + tagList: [], + showTagManagementModal: mockShowTagManagementModal, + setTagList: vi.fn(), + setShowTagManagementModal: vi.fn(), + } + return selector(state) + }, +})) + +vi.mock('@/service/tag', () => ({ + fetchTagList: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/service/use-apps', () => ({ + useInfiniteAppList: () => ({ + data: { pages: mockPages }, + isLoading: mockIsLoading, + isFetching: mockIsFetching, + isFetchingNextPage: mockIsFetchingNextPage, + fetchNextPage: mockFetchNextPage, + hasNextPage: mockHasNextPage, + error: mockError, + refetch: mockRefetch, + }), +})) + +vi.mock('@/hooks/use-pay', () => ({ + CheckModal: () => null, +})) + +vi.mock('ahooks', async () => { + const actual = await vi.importActual<typeof import('ahooks')>('ahooks') + const React = await vi.importActual<typeof import('react')>('react') + return { + ...actual, + useDebounceFn: (fn: (...args: unknown[]) => void) => { + const fnRef = React.useRef(fn) + fnRef.current = fn + return { + run: (...args: unknown[]) => fnRef.current(...args), + } + }, + } +}) + +const createMockApp = (overrides: Partial<App> = {}): App => ({ + id: overrides.id ?? 'app-1', + name: overrides.name ?? 'My Chat App', + description: overrides.description ?? 'A chat application', + author_name: overrides.author_name ?? 'Test Author', + icon_type: overrides.icon_type ?? 'emoji', + icon: overrides.icon ?? 'đŸ€–', + icon_background: overrides.icon_background ?? '#FFEAD5', + icon_url: overrides.icon_url ?? null, + use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, + mode: overrides.mode ?? AppModeEnum.CHAT, + enable_site: overrides.enable_site ?? true, + enable_api: overrides.enable_api ?? true, + api_rpm: overrides.api_rpm ?? 60, + api_rph: overrides.api_rph ?? 3600, + is_demo: overrides.is_demo ?? false, + model_config: overrides.model_config ?? {} as App['model_config'], + app_model_config: overrides.app_model_config ?? {} as App['app_model_config'], + created_at: overrides.created_at ?? 1700000000, + updated_at: overrides.updated_at ?? 1700001000, + site: overrides.site ?? {} as App['site'], + api_base_url: overrides.api_base_url ?? 'https://api.example.com', + tags: overrides.tags ?? [], + access_mode: overrides.access_mode ?? AccessMode.PUBLIC, + max_active_requests: overrides.max_active_requests ?? null, +}) + +const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse => ({ + data: apps, + has_more: hasMore, + limit: 30, + page, + total: apps.length, +}) + +const renderList = (searchParams?: Record<string, string>) => { + return render( + <NuqsTestingAdapter searchParams={searchParams}> + <List controlRefreshList={0} /> + </NuqsTestingAdapter>, + ) +} + +describe('App List Browsing Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceEditor = true + mockIsCurrentWorkspaceDatasetOperator = false + mockIsLoadingCurrentWorkspace = false + mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, + } + mockPages = [] + mockIsLoading = false + mockIsFetching = false + mockIsFetchingNextPage = false + mockHasNextPage = false + mockError = null + mockShowTagManagementModal = false + }) + + // -- Loading and Empty states -- + describe('Loading and Empty States', () => { + it('should show skeleton cards during initial loading', () => { + mockIsLoading = true + renderList() + + const skeletonCards = document.querySelectorAll('.animate-pulse') + expect(skeletonCards.length).toBeGreaterThan(0) + }) + + it('should show empty state when no apps exist', () => { + mockPages = [createPage([])] + renderList() + + expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument() + }) + + it('should transition from loading to content when data loads', () => { + mockIsLoading = true + const { rerender } = render( + <NuqsTestingAdapter> + <List controlRefreshList={0} /> + </NuqsTestingAdapter>, + ) + + const skeletonCards = document.querySelectorAll('.animate-pulse') + expect(skeletonCards.length).toBeGreaterThan(0) + + // Data loads + mockIsLoading = false + mockPages = [createPage([ + createMockApp({ id: 'app-1', name: 'Loaded App' }), + ])] + + rerender( + <NuqsTestingAdapter> + <List controlRefreshList={0} /> + </NuqsTestingAdapter>, + ) + + expect(screen.getByText('Loaded App')).toBeInTheDocument() + }) + }) + + // -- Rendering apps -- + describe('App List Rendering', () => { + it('should render all app cards from the data', () => { + mockPages = [createPage([ + createMockApp({ id: 'app-1', name: 'Chat Bot' }), + createMockApp({ id: 'app-2', name: 'Workflow Engine', mode: AppModeEnum.WORKFLOW }), + createMockApp({ id: 'app-3', name: 'Completion Tool', mode: AppModeEnum.COMPLETION }), + ])] + + renderList() + + expect(screen.getByText('Chat Bot')).toBeInTheDocument() + expect(screen.getByText('Workflow Engine')).toBeInTheDocument() + expect(screen.getByText('Completion Tool')).toBeInTheDocument() + }) + + it('should display app descriptions', () => { + mockPages = [createPage([ + createMockApp({ name: 'My App', description: 'A powerful AI assistant' }), + ])] + + renderList() + + expect(screen.getByText('A powerful AI assistant')).toBeInTheDocument() + }) + + it('should show the NewAppCard for workspace editors', () => { + mockPages = [createPage([ + createMockApp({ name: 'Test App' }), + ])] + + renderList() + + expect(screen.getByText('app.createApp')).toBeInTheDocument() + }) + + it('should hide NewAppCard when user is not a workspace editor', () => { + mockIsCurrentWorkspaceEditor = false + mockPages = [createPage([ + createMockApp({ name: 'Test App' }), + ])] + + renderList() + + expect(screen.queryByText('app.createApp')).not.toBeInTheDocument() + }) + }) + + // -- Footer visibility -- + describe('Footer Visibility', () => { + it('should show footer when branding is disabled', () => { + mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: false } } + mockPages = [createPage([createMockApp()])] + + renderList() + + expect(screen.getByText('app.join')).toBeInTheDocument() + expect(screen.getByText('app.communityIntro')).toBeInTheDocument() + }) + + it('should hide footer when branding is enabled', () => { + mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: true } } + mockPages = [createPage([createMockApp()])] + + renderList() + + expect(screen.queryByText('app.join')).not.toBeInTheDocument() + }) + }) + + // -- DSL drag-drop hint -- + describe('DSL Drag-Drop Hint', () => { + it('should show drag-drop hint for workspace editors', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument() + }) + + it('should hide drag-drop hint for non-editors', () => { + mockIsCurrentWorkspaceEditor = false + mockPages = [createPage([createMockApp()])] + renderList() + + expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument() + }) + }) + + // -- Tab navigation -- + describe('Tab Navigation', () => { + it('should render all category tabs', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + expect(screen.getByText('app.types.all')).toBeInTheDocument() + expect(screen.getByText('app.types.workflow')).toBeInTheDocument() + expect(screen.getByText('app.types.advanced')).toBeInTheDocument() + expect(screen.getByText('app.types.chatbot')).toBeInTheDocument() + expect(screen.getByText('app.types.agent')).toBeInTheDocument() + expect(screen.getByText('app.types.completion')).toBeInTheDocument() + }) + }) + + // -- Search -- + describe('Search Filtering', () => { + it('should render search input', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + const input = document.querySelector('input') + expect(input).toBeInTheDocument() + }) + + it('should allow typing in search input', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + const input = document.querySelector('input')! + fireEvent.change(input, { target: { value: 'test search' } }) + expect(input.value).toBe('test search') + }) + }) + + // -- "Created by me" filter -- + describe('Created By Me Filter', () => { + it('should render the "created by me" checkbox', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() + }) + + it('should toggle the "created by me" filter on click', () => { + mockPages = [createPage([createMockApp()])] + renderList() + + const checkbox = screen.getByText('app.showMyCreatedAppsOnly') + fireEvent.click(checkbox) + + expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() + }) + }) + + // -- Fetching next page skeleton -- + describe('Pagination Loading', () => { + it('should show skeleton when fetching next page', () => { + mockPages = [createPage([createMockApp()])] + mockIsFetchingNextPage = true + + renderList() + + const skeletonCards = document.querySelectorAll('.animate-pulse') + expect(skeletonCards.length).toBeGreaterThan(0) + }) + }) + + // -- Dataset operator redirect -- + describe('Dataset Operator Redirect', () => { + it('should redirect dataset operators to /datasets', () => { + mockIsCurrentWorkspaceDatasetOperator = true + renderList() + + expect(mockRouterReplace).toHaveBeenCalledWith('/datasets') + }) + }) + + // -- Multiple pages of data -- + describe('Multi-page Data', () => { + it('should render apps from multiple pages', () => { + mockPages = [ + createPage([ + createMockApp({ id: 'app-1', name: 'Page One App' }), + ], true, 1), + createPage([ + createMockApp({ id: 'app-2', name: 'Page Two App' }), + ], false, 2), + ] + + renderList() + + expect(screen.getByText('Page One App')).toBeInTheDocument() + expect(screen.getByText('Page Two App')).toBeInTheDocument() + }) + }) + + // -- controlRefreshList triggers refetch -- + describe('Refresh List', () => { + it('should call refetch when controlRefreshList increments', () => { + mockPages = [createPage([createMockApp()])] + + const { rerender } = render( + <NuqsTestingAdapter> + <List controlRefreshList={0} /> + </NuqsTestingAdapter>, + ) + + rerender( + <NuqsTestingAdapter> + <List controlRefreshList={1} /> + </NuqsTestingAdapter>, + ) + + expect(mockRefetch).toHaveBeenCalled() + }) + }) +}) diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx new file mode 100644 index 0000000000..23017d3c76 --- /dev/null +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -0,0 +1,465 @@ +/** + * Integration test: Create App Flow + * + * Tests the end-to-end user flows for creating new apps: + * - Creating from blank via NewAppCard + * - Creating from template via NewAppCard + * - Creating from DSL import via NewAppCard + * - Apps page top-level state management + */ +import type { AppListResponse } from '@/models/app' +import type { App } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import List from '@/app/components/apps/list' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' + +let mockIsCurrentWorkspaceEditor = true +let mockIsCurrentWorkspaceDatasetOperator = false +let mockIsLoadingCurrentWorkspace = false +let mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, +} + +let mockPages: AppListResponse[] = [] +let mockIsLoading = false +let mockIsFetching = false +const mockRefetch = vi.fn() +const mockFetchNextPage = vi.fn() +let mockShowTagManagementModal = false + +const mockRouterPush = vi.fn() +const mockRouterReplace = vi.fn() +const mockOnPlanInfoChanged = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockRouterPush, + replace: mockRouterReplace, + }), + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator, + isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => { + const state = { systemFeatures: mockSystemFeatures } + return selector ? selector(state) : state + }, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + onPlanInfoChanged: mockOnPlanInfoChanged, + }), +})) + +vi.mock('@/app/components/base/tag-management/store', () => ({ + useStore: (selector: (state: Record<string, unknown>) => unknown) => { + const state = { + tagList: [], + showTagManagementModal: mockShowTagManagementModal, + setTagList: vi.fn(), + setShowTagManagementModal: vi.fn(), + } + return selector(state) + }, +})) + +vi.mock('@/service/tag', () => ({ + fetchTagList: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/service/use-apps', () => ({ + useInfiniteAppList: () => ({ + data: { pages: mockPages }, + isLoading: mockIsLoading, + isFetching: mockIsFetching, + isFetchingNextPage: false, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + error: null, + refetch: mockRefetch, + }), +})) + +vi.mock('@/hooks/use-pay', () => ({ + CheckModal: () => null, +})) + +vi.mock('ahooks', async () => { + const actual = await vi.importActual<typeof import('ahooks')>('ahooks') + const React = await vi.importActual<typeof import('react')>('react') + return { + ...actual, + useDebounceFn: (fn: (...args: unknown[]) => void) => { + const fnRef = React.useRef(fn) + fnRef.current = fn + return { + run: (...args: unknown[]) => fnRef.current(...args), + } + }, + } +}) + +// Mock dynamically loaded modals with test stubs +vi.mock('next/dynamic', () => ({ + default: (loader: () => Promise<{ default: React.ComponentType }>) => { + let Component: React.ComponentType<Record<string, unknown>> | null = null + loader().then((mod) => { + Component = mod.default as React.ComponentType<Record<string, unknown>> + }).catch(() => {}) + const Wrapper = (props: Record<string, unknown>) => { + if (Component) + return <Component {...props} /> + return null + } + Wrapper.displayName = 'DynamicWrapper' + return Wrapper + }, +})) + +vi.mock('@/app/components/app/create-app-modal', () => ({ + default: ({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) => { + if (!show) + return null + return ( + <div data-testid="create-app-modal"> + <button data-testid="create-blank-confirm" onClick={onSuccess as () => void}>Create Blank</button> + {!!onCreateFromTemplate && ( + <button data-testid="switch-to-template" onClick={onCreateFromTemplate as () => void}>From Template</button> + )} + <button data-testid="create-blank-cancel" onClick={onClose as () => void}>Cancel</button> + </div> + ) + }, +})) + +vi.mock('@/app/components/app/create-app-dialog', () => ({ + default: ({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) => { + if (!show) + return null + return ( + <div data-testid="template-dialog"> + <button data-testid="template-confirm" onClick={onSuccess as () => void}>Create from Template</button> + {!!onCreateFromBlank && ( + <button data-testid="switch-to-blank" onClick={onCreateFromBlank as () => void}>From Blank</button> + )} + <button data-testid="template-cancel" onClick={onClose as () => void}>Cancel</button> + </div> + ) + }, +})) + +vi.mock('@/app/components/app/create-from-dsl-modal', () => ({ + default: ({ show, onClose, onSuccess }: Record<string, unknown>) => { + if (!show) + return null + return ( + <div data-testid="create-from-dsl-modal"> + <button data-testid="dsl-import-confirm" onClick={onSuccess as () => void}>Import DSL</button> + <button data-testid="dsl-import-cancel" onClick={onClose as () => void}>Cancel</button> + </div> + ) + }, + CreateFromDSLModalTab: { + FROM_URL: 'from-url', + FROM_FILE: 'from-file', + }, +})) + +const createMockApp = (overrides: Partial<App> = {}): App => ({ + id: overrides.id ?? 'app-1', + name: overrides.name ?? 'Test App', + description: overrides.description ?? 'A test app', + author_name: overrides.author_name ?? 'Author', + icon_type: overrides.icon_type ?? 'emoji', + icon: overrides.icon ?? 'đŸ€–', + icon_background: overrides.icon_background ?? '#FFEAD5', + icon_url: overrides.icon_url ?? null, + use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, + mode: overrides.mode ?? AppModeEnum.CHAT, + enable_site: overrides.enable_site ?? true, + enable_api: overrides.enable_api ?? true, + api_rpm: overrides.api_rpm ?? 60, + api_rph: overrides.api_rph ?? 3600, + is_demo: overrides.is_demo ?? false, + model_config: overrides.model_config ?? {} as App['model_config'], + app_model_config: overrides.app_model_config ?? {} as App['app_model_config'], + created_at: overrides.created_at ?? 1700000000, + updated_at: overrides.updated_at ?? 1700001000, + site: overrides.site ?? {} as App['site'], + api_base_url: overrides.api_base_url ?? 'https://api.example.com', + tags: overrides.tags ?? [], + access_mode: overrides.access_mode ?? AccessMode.PUBLIC, + max_active_requests: overrides.max_active_requests ?? null, +}) + +const createPage = (apps: App[]): AppListResponse => ({ + data: apps, + has_more: false, + limit: 30, + page: 1, + total: apps.length, +}) + +const renderList = () => { + return render( + <NuqsTestingAdapter> + <List controlRefreshList={0} /> + </NuqsTestingAdapter>, + ) +} + +describe('Create App Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceEditor = true + mockIsCurrentWorkspaceDatasetOperator = false + mockIsLoadingCurrentWorkspace = false + mockSystemFeatures = { + branding: { enabled: false }, + webapp_auth: { enabled: false }, + } + mockPages = [createPage([createMockApp()])] + mockIsLoading = false + mockIsFetching = false + mockShowTagManagementModal = false + }) + + // -- NewAppCard rendering -- + describe('NewAppCard Rendering', () => { + it('should render the "Create App" card with all options', () => { + renderList() + + expect(screen.getByText('app.createApp')).toBeInTheDocument() + expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument() + expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument() + expect(screen.getByText('app.importDSL')).toBeInTheDocument() + }) + + it('should not render NewAppCard when user is not an editor', () => { + mockIsCurrentWorkspaceEditor = false + renderList() + + expect(screen.queryByText('app.createApp')).not.toBeInTheDocument() + }) + + it('should show loading state when workspace is loading', () => { + mockIsLoadingCurrentWorkspace = true + renderList() + + // NewAppCard renders but with loading style (pointer-events-none opacity-50) + expect(screen.getByText('app.createApp')).toBeInTheDocument() + }) + }) + + // -- Create from blank -- + describe('Create from Blank Flow', () => { + it('should open the create app modal when "Start from Blank" is clicked', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + }) + }) + + it('should close the create app modal on cancel', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('create-blank-cancel')) + await waitFor(() => { + expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() + }) + }) + + it('should call onPlanInfoChanged and refetch on successful creation', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('create-blank-confirm')) + await waitFor(() => { + expect(mockOnPlanInfoChanged).toHaveBeenCalled() + expect(mockRefetch).toHaveBeenCalled() + }) + }) + }) + + // -- Create from template -- + describe('Create from Template Flow', () => { + it('should open template dialog when "Start from Template" is clicked', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromTemplate')) + + await waitFor(() => { + expect(screen.getByTestId('template-dialog')).toBeInTheDocument() + }) + }) + + it('should allow switching from template to blank modal', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromTemplate')) + await waitFor(() => { + expect(screen.getByTestId('template-dialog')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('switch-to-blank')) + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + expect(screen.queryByTestId('template-dialog')).not.toBeInTheDocument() + }) + }) + + it('should allow switching from blank to template dialog', async () => { + renderList() + + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + await waitFor(() => { + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('switch-to-template')) + await waitFor(() => { + expect(screen.getByTestId('template-dialog')).toBeInTheDocument() + expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() + }) + }) + }) + + // -- Create from DSL import (via NewAppCard button) -- + describe('Create from DSL Import Flow', () => { + it('should open DSL import modal when "Import DSL" is clicked', async () => { + renderList() + + fireEvent.click(screen.getByText('app.importDSL')) + + await waitFor(() => { + expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() + }) + }) + + it('should close DSL import modal on cancel', async () => { + renderList() + + fireEvent.click(screen.getByText('app.importDSL')) + await waitFor(() => { + expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('dsl-import-cancel')) + await waitFor(() => { + expect(screen.queryByTestId('create-from-dsl-modal')).not.toBeInTheDocument() + }) + }) + + it('should call onPlanInfoChanged and refetch on successful DSL import', async () => { + renderList() + + fireEvent.click(screen.getByText('app.importDSL')) + await waitFor(() => { + expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('dsl-import-confirm')) + await waitFor(() => { + expect(mockOnPlanInfoChanged).toHaveBeenCalled() + expect(mockRefetch).toHaveBeenCalled() + }) + }) + }) + + // -- DSL drag-and-drop flow (via List component) -- + describe('DSL Drag-Drop Flow', () => { + it('should show drag-drop hint in the list', () => { + renderList() + + expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument() + }) + + it('should open create-from-DSL modal when DSL file is dropped', async () => { + const { act } = await import('@testing-library/react') + renderList() + + const container = document.querySelector('[class*="overflow-y-auto"]') + if (container) { + const yamlFile = new File(['app: test'], 'app.yaml', { type: 'application/yaml' }) + + // Simulate the full drag-drop sequence wrapped in act + await act(async () => { + const dragEnterEvent = new Event('dragenter', { bubbles: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'], files: [] }, + }) + Object.defineProperty(dragEnterEvent, 'preventDefault', { value: vi.fn() }) + Object.defineProperty(dragEnterEvent, 'stopPropagation', { value: vi.fn() }) + container.dispatchEvent(dragEnterEvent) + + const dropEvent = new Event('drop', { bubbles: true }) + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [yamlFile], types: ['Files'] }, + }) + Object.defineProperty(dropEvent, 'preventDefault', { value: vi.fn() }) + Object.defineProperty(dropEvent, 'stopPropagation', { value: vi.fn() }) + container.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + const modal = screen.queryByTestId('create-from-dsl-modal') + if (modal) + expect(modal).toBeInTheDocument() + }) + } + }) + }) + + // -- Edge cases -- + describe('Edge Cases', () => { + it('should not show create options when no data and user is editor', () => { + mockPages = [createPage([])] + renderList() + + // NewAppCard should still be visible even with no apps + expect(screen.getByText('app.createApp')).toBeInTheDocument() + }) + + it('should handle multiple rapid clicks on create buttons without crashing', async () => { + renderList() + + // Rapidly click different create options + fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + fireEvent.click(screen.getByText('app.newApp.startFromTemplate')) + fireEvent.click(screen.getByText('app.importDSL')) + + // Should not crash, and some modal should be present + await waitFor(() => { + const anyModal = screen.queryByTestId('create-app-modal') + || screen.queryByTestId('template-dialog') + || screen.queryByTestId('create-from-dsl-modal') + expect(anyModal).toBeTruthy() + }) + }) + }) +}) diff --git a/web/app/components/apps/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx similarity index 96% rename from web/app/components/apps/app-card.spec.tsx rename to web/app/components/apps/__tests__/app-card.spec.tsx index a9012dbbe8..ee36d471fd 100644 --- a/web/app/components/apps/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -1,16 +1,13 @@ import type { Mock } from 'vitest' +import type { App } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { AccessMode } from '@/models/access-control' -// Mock API services - import for direct manipulation import * as appsService from '@/service/apps' - import * as exploreService from '@/service/explore' import * as workflowService from '@/service/workflow' import { AppModeEnum } from '@/types/app' - -// Import component after mocks -import AppCard from './app-card' +import AppCard from '../app-card' // Mock next/navigation const mockPush = vi.fn() @@ -24,11 +21,11 @@ vi.mock('next/navigation', () => ({ // Include createContext for components that use it (like Toast) const mockNotify = vi.fn() vi.mock('use-context-selector', () => ({ - createContext: (defaultValue: any) => React.createContext(defaultValue), + createContext: <T,>(defaultValue: T) => React.createContext(defaultValue), useContext: () => ({ notify: mockNotify, }), - useContextSelector: (_context: any, selector: any) => selector({ + useContextSelector: (_context: unknown, selector: (state: Record<string, unknown>) => unknown) => selector({ notify: mockNotify, }), })) @@ -51,7 +48,7 @@ vi.mock('@/context/provider-context', () => ({ // Mock global public store - allow dynamic configuration let mockWebappAuthEnabled = false vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (s: any) => any) => selector({ + useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) => selector({ systemFeatures: { webapp_auth: { enabled: mockWebappAuthEnabled }, branding: { enabled: false }, @@ -106,11 +103,11 @@ vi.mock('@/utils/time', () => ({ // Mock dynamic imports vi.mock('next/dynamic', () => ({ - default: (importFn: () => Promise<any>) => { + default: (importFn: () => Promise<unknown>) => { const fnString = importFn.toString() if (fnString.includes('create-app-modal') || fnString.includes('explore/create-app-modal')) { - return function MockEditAppModal({ show, onHide, onConfirm }: any) { + return function MockEditAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record<string, unknown>) => void }) { if (!show) return null return React.createElement('div', { 'data-testid': 'edit-app-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-edit-modal' }, 'Close'), React.createElement('button', { @@ -128,7 +125,7 @@ vi.mock('next/dynamic', () => ({ } } if (fnString.includes('duplicate-modal')) { - return function MockDuplicateAppModal({ show, onHide, onConfirm }: any) { + return function MockDuplicateAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record<string, unknown>) => void }) { if (!show) return null return React.createElement('div', { 'data-testid': 'duplicate-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-duplicate-modal' }, 'Close'), React.createElement('button', { @@ -143,26 +140,26 @@ vi.mock('next/dynamic', () => ({ } } if (fnString.includes('switch-app-modal')) { - return function MockSwitchAppModal({ show, onClose, onSuccess }: any) { + return function MockSwitchAppModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) { if (!show) return null return React.createElement('div', { 'data-testid': 'switch-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-switch-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'confirm-switch-modal' }, 'Switch')) } } if (fnString.includes('base/confirm')) { - return function MockConfirm({ isShow, onCancel, onConfirm }: any) { + return function MockConfirm({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) { if (!isShow) return null return React.createElement('div', { 'data-testid': 'confirm-dialog' }, React.createElement('button', { 'onClick': onCancel, 'data-testid': 'cancel-confirm' }, 'Cancel'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-confirm' }, 'Confirm')) } } if (fnString.includes('dsl-export-confirm-modal')) { - return function MockDSLExportModal({ onClose, onConfirm }: any) { + return function MockDSLExportModal({ onClose, onConfirm }: { onClose?: () => void, onConfirm?: (withSecrets: boolean) => void }) { return React.createElement('div', { 'data-testid': 'dsl-export-modal' }, React.createElement('button', { 'onClick': () => onClose?.(), 'data-testid': 'close-dsl-export' }, 'Close'), React.createElement('button', { 'onClick': () => onConfirm?.(true), 'data-testid': 'confirm-dsl-export' }, 'Export with secrets'), React.createElement('button', { 'onClick': () => onConfirm?.(false), 'data-testid': 'confirm-dsl-export-no-secrets' }, 'Export without secrets')) } } if (fnString.includes('app-access-control')) { - return function MockAccessControl({ onClose, onConfirm }: any) { + return function MockAccessControl({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) { return React.createElement('div', { 'data-testid': 'access-control-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-access-control' }, 'Close'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-access-control' }, 'Confirm')) } } @@ -172,7 +169,9 @@ vi.mock('next/dynamic', () => ({ // Popover uses @headlessui/react portals - mock for controlled interaction testing vi.mock('@/app/components/base/popover', () => { - const MockPopover = ({ htmlContent, btnElement, btnClassName }: any) => { + type PopoverHtmlContent = React.ReactNode | ((state: { open: boolean, onClose: () => void, onClick: () => void }) => React.ReactNode) + type MockPopoverProps = { htmlContent: PopoverHtmlContent, btnElement: React.ReactNode, btnClassName?: string | ((open: boolean) => string) } + const MockPopover = ({ htmlContent, btnElement, btnClassName }: MockPopoverProps) => { const [isOpen, setIsOpen] = React.useState(false) const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : '' return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName }, React.createElement('div', { @@ -188,13 +187,13 @@ vi.mock('@/app/components/base/popover', () => { // Tooltip uses portals - minimal mock preserving popup content as title attribute vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: any) => React.createElement('div', { title: popupContent }, children), + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: React.ReactNode }) => React.createElement('div', { title: popupContent }, children), })) // TagSelector has API dependency (service/tag) - mock for isolated testing vi.mock('@/app/components/base/tag-management/selector', () => ({ - default: ({ tags }: any) => { - return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: any) => React.createElement('span', { key: tag.id }, tag.name))) + default: ({ tags }: { tags?: { id: string, name: string }[] }) => { + return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: { id: string, name: string }) => React.createElement('span', { key: tag.id }, tag.name))) }, })) @@ -203,11 +202,7 @@ vi.mock('@/app/components/app/type-selector', () => ({ AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }), })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - -const createMockApp = (overrides: Record<string, any> = {}) => ({ +const createMockApp = (overrides: Partial<App> = {}): App => ({ id: 'test-app-id', name: 'Test App', description: 'Test app description', @@ -229,16 +224,8 @@ const createMockApp = (overrides: Record<string, any> = {}) => ({ api_rpm: 60, api_rph: 3600, is_demo: false, - model_config: {} as any, - app_model_config: {} as any, - site: {} as any, - api_base_url: 'https://api.example.com', ...overrides, -}) - -// ============================================================================ -// Tests -// ============================================================================ +} as App) describe('AppCard', () => { const mockApp = createMockApp() @@ -1171,7 +1158,7 @@ describe('AppCard', () => { (exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce(new Error('API Error')) // Configure mockOpenAsyncWindow to call the callback and trigger error - mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options: any) => { + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => { try { await callback() } @@ -1213,7 +1200,7 @@ describe('AppCard', () => { (exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] }) // Configure mockOpenAsyncWindow to call the callback and trigger error - mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options: any) => { + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => { try { await callback() } diff --git a/web/app/components/apps/empty.spec.tsx b/web/app/components/apps/__tests__/empty.spec.tsx similarity index 95% rename from web/app/components/apps/empty.spec.tsx rename to web/app/components/apps/__tests__/empty.spec.tsx index 58a96f313a..8dbbbc3ffb 100644 --- a/web/app/components/apps/empty.spec.tsx +++ b/web/app/components/apps/__tests__/empty.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import Empty from './empty' +import Empty from '../empty' describe('Empty', () => { beforeEach(() => { @@ -21,7 +21,6 @@ describe('Empty', () => { it('should display the no apps found message', () => { render(<Empty />) - // Use pattern matching for resilient text assertions expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument() }) }) diff --git a/web/app/components/apps/footer.spec.tsx b/web/app/components/apps/__tests__/footer.spec.tsx similarity index 97% rename from web/app/components/apps/footer.spec.tsx rename to web/app/components/apps/__tests__/footer.spec.tsx index d93869b480..bbcad8c551 100644 --- a/web/app/components/apps/footer.spec.tsx +++ b/web/app/components/apps/__tests__/footer.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import Footer from './footer' +import Footer from '../footer' describe('Footer', () => { beforeEach(() => { @@ -15,7 +15,6 @@ describe('Footer', () => { it('should display the community heading', () => { render(<Footer />) - // Use pattern matching for resilient text assertions expect(screen.getByText('app.join')).toBeInTheDocument() }) diff --git a/web/app/components/apps/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx similarity index 93% rename from web/app/components/apps/index.spec.tsx rename to web/app/components/apps/__tests__/index.spec.tsx index c77c1bdb01..da4fbc2d44 100644 --- a/web/app/components/apps/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -3,21 +3,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import * as React from 'react' -// Import after mocks -import Apps from './index' +import Apps from '../index' -// Track mock calls let documentTitleCalls: string[] = [] let educationInitCalls: number = 0 -// Mock useDocumentTitle hook vi.mock('@/hooks/use-document-title', () => ({ default: (title: string) => { documentTitleCalls.push(title) }, })) -// Mock useEducationInit hook vi.mock('@/app/education-apply/hooks', () => ({ useEducationInit: () => { educationInitCalls++ @@ -33,8 +29,7 @@ vi.mock('@/hooks/use-import-dsl', () => ({ }), })) -// Mock List component -vi.mock('./list', () => ({ +vi.mock('../list', () => ({ default: () => { return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List') }, @@ -100,10 +95,7 @@ describe('Apps', () => { it('should render full component tree', () => { renderWithClient(<Apps />) - // Verify container exists expect(screen.getByTestId('apps-list')).toBeInTheDocument() - - // Verify hooks were called expect(documentTitleCalls.length).toBeGreaterThanOrEqual(1) expect(educationInitCalls).toBeGreaterThanOrEqual(1) }) diff --git a/web/app/components/apps/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx similarity index 67% rename from web/app/components/apps/list.spec.tsx rename to web/app/components/apps/__tests__/list.spec.tsx index 32bf5929fd..2d4013012f 100644 --- a/web/app/components/apps/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -1,12 +1,13 @@ +import type { UrlUpdateEvent } from 'nuqs/adapters/testing' +import type { ReactNode } from 'react' import { act, fireEvent, render, screen } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import * as React from 'react' import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { AppModeEnum } from '@/types/app' -// Import after mocks -import List from './list' +import List from '../list' -// Mock next/navigation const mockReplace = vi.fn() const mockRouter = { replace: mockReplace } vi.mock('next/navigation', () => ({ @@ -14,7 +15,6 @@ vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams(''), })) -// Mock app context const mockIsCurrentWorkspaceEditor = vi.fn(() => true) const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false) vi.mock('@/context/app-context', () => ({ @@ -24,7 +24,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -// Mock global public store vi.mock('@/context/global-public-context', () => ({ useGlobalPublicStore: () => ({ systemFeatures: { @@ -33,41 +32,28 @@ vi.mock('@/context/global-public-context', () => ({ }), })) -// Mock custom hooks - allow dynamic query state const mockSetQuery = vi.fn() const mockQueryState = { tagIDs: [] as string[], keywords: '', isCreatedByMe: false, } -vi.mock('./hooks/use-apps-query-state', () => ({ +vi.mock('../hooks/use-apps-query-state', () => ({ default: () => ({ query: mockQueryState, setQuery: mockSetQuery, }), })) -// Store callback for testing DSL file drop let mockOnDSLFileDropped: ((file: File) => void) | null = null let mockDragging = false -vi.mock('./hooks/use-dsl-drag-drop', () => ({ +vi.mock('../hooks/use-dsl-drag-drop', () => ({ useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => { mockOnDSLFileDropped = onDSLFileDropped return { dragging: mockDragging } }, })) -const mockSetActiveTab = vi.fn() -vi.mock('nuqs', () => ({ - useQueryState: () => ['all', mockSetActiveTab], - parseAsString: { - withDefault: () => ({ - withOptions: () => ({}), - }), - }, -})) - -// Mock service hooks - use object for mutable state (vi.mock is hoisted) const mockRefetch = vi.fn() const mockFetchNextPage = vi.fn() @@ -124,47 +110,20 @@ vi.mock('@/service/use-apps', () => ({ }), })) -// Use real tag store - global zustand mock will auto-reset between tests - -// Mock tag service to avoid API calls in TagFilter vi.mock('@/service/tag', () => ({ fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]), })) -// Store TagFilter onChange callback for testing -let mockTagFilterOnChange: ((value: string[]) => void) | null = null -vi.mock('@/app/components/base/tag-management/filter', () => ({ - default: ({ onChange }: { onChange: (value: string[]) => void }) => { - mockTagFilterOnChange = onChange - return React.createElement('div', { 'data-testid': 'tag-filter' }, 'common.tag.placeholder') - }, -})) - -// Mock config vi.mock('@/config', () => ({ NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList', })) -// Mock pay hook vi.mock('@/hooks/use-pay', () => ({ CheckModal: () => null, })) -// Mock ahooks - useMount only executes once on mount, not on fn change -vi.mock('ahooks', () => ({ - useDebounceFn: (fn: () => void) => ({ run: fn }), - useMount: (fn: () => void) => { - const fnRef = React.useRef(fn) - fnRef.current = fn - React.useEffect(() => { - fnRef.current() - }, []) - }, -})) - -// Mock dynamic imports vi.mock('next/dynamic', () => ({ - default: (importFn: () => Promise<any>) => { + default: (importFn: () => Promise<unknown>) => { const fnString = importFn.toString() if (fnString.includes('tag-management')) { @@ -173,7 +132,7 @@ vi.mock('next/dynamic', () => ({ } } if (fnString.includes('create-from-dsl-modal')) { - return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) { + return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) { if (!show) return null return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success')) @@ -183,41 +142,34 @@ vi.mock('next/dynamic', () => ({ }, })) -/** - * Mock child components for focused List component testing. - * These mocks isolate the List component's behavior from its children. - * Each child component (AppCard, NewAppCard, Empty, Footer) has its own dedicated tests. - */ -vi.mock('./app-card', () => ({ - default: ({ app }: any) => { +vi.mock('../app-card', () => ({ + default: ({ app }: { app: { id: string, name: string } }) => { return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name) }, })) -vi.mock('./new-app-card', () => ({ - default: React.forwardRef((_props: any, _ref: any) => { +vi.mock('../new-app-card', () => ({ + default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => { return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card') }), })) -vi.mock('./empty', () => ({ +vi.mock('../empty', () => ({ default: () => { return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found') }, })) -vi.mock('./footer', () => ({ +vi.mock('../footer', () => ({ default: () => { return React.createElement('footer', { 'data-testid': 'footer', 'role': 'contentinfo' }, 'Footer') }, })) -// Store IntersectionObserver callback let intersectionCallback: IntersectionObserverCallback | null = null const mockObserve = vi.fn() const mockDisconnect = vi.fn() -// Mock IntersectionObserver beforeAll(() => { globalThis.IntersectionObserver = class MockIntersectionObserver { constructor(callback: IntersectionObserverCallback) { @@ -234,10 +186,21 @@ beforeAll(() => { } as unknown as typeof IntersectionObserver }) +// Render helper wrapping with NuqsTestingAdapter +const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() +const renderList = (searchParams = '') => { + const wrapper = ({ children }: { children: ReactNode }) => ( + <NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}> + {children} + </NuqsTestingAdapter> + ) + return render(<List />, { wrapper }) +} + describe('List', () => { beforeEach(() => { vi.clearAllMocks() - // Set up tag store state + onUrlUpdate.mockClear() useTagStore.setState({ tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }], showTagManagementModal: false, @@ -246,7 +209,6 @@ describe('List', () => { mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false) mockDragging = false mockOnDSLFileDropped = null - mockTagFilterOnChange = null mockServiceState.error = null mockServiceState.hasNextPage = false mockServiceState.isLoading = false @@ -260,13 +222,12 @@ describe('List', () => { describe('Rendering', () => { it('should render without crashing', () => { - render(<List />) - // Tab slider renders app type tabs + renderList() expect(screen.getByText('app.types.all')).toBeInTheDocument() }) it('should render tab slider with all app types', () => { - render(<List />) + renderList() expect(screen.getByText('app.types.all')).toBeInTheDocument() expect(screen.getByText('app.types.workflow')).toBeInTheDocument() @@ -277,71 +238,74 @@ describe('List', () => { }) it('should render search input', () => { - render(<List />) - // Input component renders a searchbox + renderList() expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render tag filter', () => { - render(<List />) - // Tag filter renders with placeholder text + renderList() expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() }) it('should render created by me checkbox', () => { - render(<List />) + renderList() expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() }) it('should render app cards when apps exist', () => { - render(<List />) + renderList() expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument() }) it('should render new app card for editors', () => { - render(<List />) + renderList() expect(screen.getByTestId('new-app-card')).toBeInTheDocument() }) it('should render footer when branding is disabled', () => { - render(<List />) + renderList() expect(screen.getByTestId('footer')).toBeInTheDocument() }) it('should render drop DSL hint for editors', () => { - render(<List />) + renderList() expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument() }) }) describe('Tab Navigation', () => { - it('should call setActiveTab when tab is clicked', () => { - render(<List />) + it('should update URL when workflow tab is clicked', async () => { + renderList() fireEvent.click(screen.getByText('app.types.workflow')) - expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW) + await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW) }) - it('should call setActiveTab for all tab', () => { - render(<List />) + it('should update URL when all tab is clicked', async () => { + renderList('?category=workflow') fireEvent.click(screen.getByText('app.types.all')) - expect(mockSetActiveTab).toHaveBeenCalledWith('all') + await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + // nuqs removes the default value ('all') from URL params + expect(lastCall.searchParams.has('category')).toBe(false) }) }) describe('Search Functionality', () => { it('should render search input field', () => { - render(<List />) + renderList() expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should handle search input change', () => { - render(<List />) + renderList() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'test search' } }) @@ -349,55 +313,36 @@ describe('List', () => { expect(mockSetQuery).toHaveBeenCalled() }) - it('should handle search input interaction', () => { - render(<List />) - - const input = screen.getByRole('textbox') - expect(input).toBeInTheDocument() - }) - it('should handle search clear button click', () => { - // Set initial keywords to make clear button visible mockQueryState.keywords = 'existing search' - render(<List />) + renderList() - // Find and click clear button (Input component uses .group class for clear icon container) const clearButton = document.querySelector('.group') expect(clearButton).toBeInTheDocument() if (clearButton) fireEvent.click(clearButton) - // handleKeywordsChange should be called with empty string expect(mockSetQuery).toHaveBeenCalled() }) }) describe('Tag Filter', () => { it('should render tag filter component', () => { - render(<List />) - expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() - }) - - it('should render tag filter with placeholder', () => { - render(<List />) - - // Tag filter is rendered + renderList() expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() }) }) describe('Created By Me Filter', () => { it('should render checkbox with correct label', () => { - render(<List />) + renderList() expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() }) it('should handle checkbox change', () => { - render(<List />) + renderList() - // Checkbox component uses data-testid="checkbox-{id}" - // CheckboxWithLabel doesn't pass testId, so id is undefined const checkbox = screen.getByTestId('checkbox-undefined') fireEvent.click(checkbox) @@ -409,7 +354,7 @@ describe('List', () => { it('should not render new app card for non-editors', () => { mockIsCurrentWorkspaceEditor.mockReturnValue(false) - render(<List />) + renderList() expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument() }) @@ -417,7 +362,7 @@ describe('List', () => { it('should not render drop DSL hint for non-editors', () => { mockIsCurrentWorkspaceEditor.mockReturnValue(false) - render(<List />) + renderList() expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument() }) @@ -427,7 +372,7 @@ describe('List', () => { it('should redirect dataset operators to datasets page', () => { mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true) - render(<List />) + renderList() expect(mockReplace).toHaveBeenCalledWith('/datasets') }) @@ -437,7 +382,7 @@ describe('List', () => { it('should call refetch when refresh key is set in localStorage', () => { localStorage.setItem('needRefreshAppList', '1') - render(<List />) + renderList() expect(mockRefetch).toHaveBeenCalled() expect(localStorage.getItem('needRefreshAppList')).toBeNull() @@ -446,22 +391,30 @@ describe('List', () => { describe('Edge Cases', () => { it('should handle multiple renders without issues', () => { - const { rerender } = render(<List />) + const { rerender } = render( + <NuqsTestingAdapter> + <List /> + </NuqsTestingAdapter>, + ) expect(screen.getByText('app.types.all')).toBeInTheDocument() - rerender(<List />) + rerender( + <NuqsTestingAdapter> + <List /> + </NuqsTestingAdapter>, + ) expect(screen.getByText('app.types.all')).toBeInTheDocument() }) it('should render app cards correctly', () => { - render(<List />) + renderList() expect(screen.getByText('Test App 1')).toBeInTheDocument() expect(screen.getByText('Test App 2')).toBeInTheDocument() }) it('should render with all filter options visible', () => { - render(<List />) + renderList() expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() @@ -471,14 +424,20 @@ describe('List', () => { describe('Dragging State', () => { it('should show drop hint when DSL feature is enabled for editors', () => { - render(<List />) + renderList() expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument() }) + + it('should render dragging state overlay when dragging', () => { + mockDragging = true + const { container } = renderList() + expect(container).toBeInTheDocument() + }) }) describe('App Type Tabs', () => { it('should render all app type tabs', () => { - render(<List />) + renderList() expect(screen.getByText('app.types.all')).toBeInTheDocument() expect(screen.getByText('app.types.workflow')).toBeInTheDocument() @@ -488,8 +447,8 @@ describe('List', () => { expect(screen.getByText('app.types.completion')).toBeInTheDocument() }) - it('should call setActiveTab for each app type', () => { - render(<List />) + it('should update URL for each app type tab click', async () => { + renderList() const appTypeTexts = [ { mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' }, @@ -499,45 +458,26 @@ describe('List', () => { { mode: AppModeEnum.COMPLETION, text: 'app.types.completion' }, ] - appTypeTexts.forEach(({ mode, text }) => { + for (const { mode, text } of appTypeTexts) { + onUrlUpdate.mockClear() fireEvent.click(screen.getByText(text)) - expect(mockSetActiveTab).toHaveBeenCalledWith(mode) - }) - }) - }) - - describe('Search and Filter Integration', () => { - it('should display search input with correct attributes', () => { - render(<List />) - - const input = screen.getByRole('textbox') - expect(input).toBeInTheDocument() - expect(input).toHaveAttribute('value', '') - }) - - it('should have tag filter component', () => { - render(<List />) - - expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() - }) - - it('should display created by me label', () => { - render(<List />) - - expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() + await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(lastCall.searchParams.get('category')).toBe(mode) + } }) }) describe('App List Display', () => { it('should display all app cards from data', () => { - render(<List />) + renderList() expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument() }) it('should display app names correctly', () => { - render(<List />) + renderList() expect(screen.getByText('Test App 1')).toBeInTheDocument() expect(screen.getByText('Test App 2')).toBeInTheDocument() @@ -546,59 +486,27 @@ describe('List', () => { describe('Footer Visibility', () => { it('should render footer when branding is disabled', () => { - render(<List />) - + renderList() expect(screen.getByTestId('footer')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- - // Additional Coverage Tests - // -------------------------------------------------------------------------- - describe('Additional Coverage', () => { - it('should render dragging state overlay when dragging', () => { - mockDragging = true - const { container } = render(<List />) - - // Component should render successfully with dragging state - expect(container).toBeInTheDocument() - }) - - it('should handle app mode filter in query params', () => { - render(<List />) - - const workflowTab = screen.getByText('app.types.workflow') - fireEvent.click(workflowTab) - - expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW) - }) - - it('should render new app card for editors', () => { - render(<List />) - - expect(screen.getByTestId('new-app-card')).toBeInTheDocument() - }) - }) - describe('DSL File Drop', () => { it('should handle DSL file drop and show modal', () => { - render(<List />) + renderList() - // Simulate DSL file drop via the callback const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) act(() => { if (mockOnDSLFileDropped) mockOnDSLFileDropped(mockFile) }) - // Modal should be shown expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument() }) it('should close DSL modal when onClose is called', () => { - render(<List />) + renderList() - // Open modal via DSL file drop const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) act(() => { if (mockOnDSLFileDropped) @@ -607,16 +515,14 @@ describe('List', () => { expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument() - // Close modal fireEvent.click(screen.getByTestId('close-dsl-modal')) expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() }) it('should close DSL modal and refetch when onSuccess is called', () => { - render(<List />) + renderList() - // Open modal via DSL file drop const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) act(() => { if (mockOnDSLFileDropped) @@ -625,67 +531,18 @@ describe('List', () => { expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument() - // Click success button fireEvent.click(screen.getByTestId('success-dsl-modal')) - // Modal should be closed and refetch should be called expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() expect(mockRefetch).toHaveBeenCalled() }) }) - describe('Tag Filter Change', () => { - it('should handle tag filter value change', () => { - vi.useFakeTimers() - render(<List />) - - // TagFilter component is rendered - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() - - // Trigger tag filter change via captured callback - act(() => { - if (mockTagFilterOnChange) - mockTagFilterOnChange(['tag-1', 'tag-2']) - }) - - // Advance timers to trigger debounced setTagIDs - act(() => { - vi.advanceTimersByTime(500) - }) - - // setQuery should have been called with updated tagIDs - expect(mockSetQuery).toHaveBeenCalled() - - vi.useRealTimers() - }) - - it('should handle empty tag filter selection', () => { - vi.useFakeTimers() - render(<List />) - - // Trigger tag filter change with empty array - act(() => { - if (mockTagFilterOnChange) - mockTagFilterOnChange([]) - }) - - // Advance timers - act(() => { - vi.advanceTimersByTime(500) - }) - - expect(mockSetQuery).toHaveBeenCalled() - - vi.useRealTimers() - }) - }) - describe('Infinite Scroll', () => { it('should call fetchNextPage when intersection observer triggers', () => { mockServiceState.hasNextPage = true - render(<List />) + renderList() - // Simulate intersection if (intersectionCallback) { act(() => { intersectionCallback!( @@ -700,9 +557,8 @@ describe('List', () => { it('should not call fetchNextPage when not intersecting', () => { mockServiceState.hasNextPage = true - render(<List />) + renderList() - // Simulate non-intersection if (intersectionCallback) { act(() => { intersectionCallback!( @@ -718,7 +574,7 @@ describe('List', () => { it('should not call fetchNextPage when loading', () => { mockServiceState.hasNextPage = true mockServiceState.isLoading = true - render(<List />) + renderList() if (intersectionCallback) { act(() => { @@ -736,11 +592,8 @@ describe('List', () => { describe('Error State', () => { it('should handle error state in useEffect', () => { mockServiceState.error = new Error('Test error') - const { container } = render(<List />) - - // Component should still render + const { container } = renderList() expect(container).toBeInTheDocument() - // Disconnect should be called when there's an error (cleanup) }) }) }) diff --git a/web/app/components/apps/new-app-card.spec.tsx b/web/app/components/apps/__tests__/new-app-card.spec.tsx similarity index 87% rename from web/app/components/apps/new-app-card.spec.tsx rename to web/app/components/apps/__tests__/new-app-card.spec.tsx index 92e769adc7..f4c357b9f9 100644 --- a/web/app/components/apps/new-app-card.spec.tsx +++ b/web/app/components/apps/__tests__/new-app-card.spec.tsx @@ -1,10 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -// Import after mocks -import CreateAppCard from './new-app-card' +import CreateAppCard from '../new-app-card' -// Mock next/navigation const mockReplace = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -13,7 +11,6 @@ vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams(), })) -// Mock provider context const mockOnPlanInfoChanged = vi.fn() vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ @@ -21,37 +18,35 @@ vi.mock('@/context/provider-context', () => ({ }), })) -// Mock next/dynamic to immediately resolve components vi.mock('next/dynamic', () => ({ - default: (importFn: () => Promise<any>) => { + default: (importFn: () => Promise<{ default: React.ComponentType }>) => { const fnString = importFn.toString() if (fnString.includes('create-app-modal') && !fnString.includes('create-from-dsl-modal')) { - return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: any) { + return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) { if (!show) return null - return React.createElement('div', { 'data-testid': 'create-app-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-create-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-create-modal' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromTemplate, 'data-testid': 'to-template-modal' }, 'To Template')) + return React.createElement('div', { 'data-testid': 'create-app-modal' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-create-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-create-modal' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromTemplate as () => void, 'data-testid': 'to-template-modal' }, 'To Template')) } } if (fnString.includes('create-app-dialog')) { - return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: any) { + return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) { if (!show) return null - return React.createElement('div', { 'data-testid': 'create-template-dialog' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-template-dialog' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-template-dialog' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromBlank, 'data-testid': 'to-blank-modal' }, 'To Blank')) + return React.createElement('div', { 'data-testid': 'create-template-dialog' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-template-dialog' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-template-dialog' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromBlank as () => void, 'data-testid': 'to-blank-modal' }, 'To Blank')) } } if (fnString.includes('create-from-dsl-modal')) { - return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) { + return function MockCreateFromDSLModal({ show, onClose, onSuccess }: Record<string, unknown>) { if (!show) return null - return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success')) + return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-dsl-modal' }, 'Success')) } } return () => null }, })) -// Mock CreateFromDSLModalTab enum vi.mock('@/app/components/app/create-from-dsl-modal', () => ({ CreateFromDSLModalTab: { FROM_URL: 'from-url', @@ -68,7 +63,6 @@ describe('CreateAppCard', () => { describe('Rendering', () => { it('should render without crashing', () => { render(<CreateAppCard ref={defaultRef} />) - // Use pattern matching for resilient text assertions expect(screen.getByText('app.createApp')).toBeInTheDocument() }) @@ -245,19 +239,15 @@ describe('CreateAppCard', () => { it('should handle multiple modal opens/closes', () => { render(<CreateAppCard ref={defaultRef} />) - // Open and close create modal fireEvent.click(screen.getByText('app.newApp.startFromBlank')) fireEvent.click(screen.getByTestId('close-create-modal')) - // Open and close template dialog fireEvent.click(screen.getByText('app.newApp.startFromTemplate')) fireEvent.click(screen.getByTestId('close-template-dialog')) - // Open and close DSL modal fireEvent.click(screen.getByText('app.importDSL')) fireEvent.click(screen.getByTestId('close-dsl-modal')) - // No modals should be visible expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument() expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() @@ -267,7 +257,6 @@ describe('CreateAppCard', () => { render(<CreateAppCard ref={defaultRef} />) fireEvent.click(screen.getByText('app.newApp.startFromBlank')) - // This should not throw an error expect(() => { fireEvent.click(screen.getByTestId('success-create-modal')) }).not.toThrow() diff --git a/web/app/components/apps/hooks/use-apps-query-state.spec.tsx b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx similarity index 91% rename from web/app/components/apps/hooks/use-apps-query-state.spec.tsx rename to web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx index 29f2e17556..0c956b78a4 100644 --- a/web/app/components/apps/hooks/use-apps-query-state.spec.tsx +++ b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx @@ -1,16 +1,8 @@ import type { UrlUpdateEvent } from 'nuqs/adapters/testing' import type { ReactNode } from 'react' -/** - * Test suite for useAppsQueryState hook - * - * This hook manages app filtering state through URL search parameters, enabling: - * - Bookmarkable filter states (users can share URLs with specific filters active) - * - Browser history integration (back/forward buttons work with filters) - * - Multiple filter types: tagIDs, keywords, isCreatedByMe - */ import { act, renderHook, waitFor } from '@testing-library/react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' -import useAppsQueryState from './use-apps-query-state' +import useAppsQueryState from '../use-apps-query-state' const renderWithAdapter = (searchParams = '') => { const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() @@ -23,13 +15,11 @@ const renderWithAdapter = (searchParams = '') => { return { result, onUrlUpdate } } -// Groups scenarios for useAppsQueryState behavior. describe('useAppsQueryState', () => { beforeEach(() => { vi.clearAllMocks() }) - // Covers the hook return shape and default values. describe('Initialization', () => { it('should expose query and setQuery when initialized', () => { const { result } = renderWithAdapter() @@ -47,7 +37,6 @@ describe('useAppsQueryState', () => { }) }) - // Covers parsing of existing URL search params. describe('Parsing search params', () => { it('should parse tagIDs when URL includes tagIDs', () => { const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3') @@ -78,7 +67,6 @@ describe('useAppsQueryState', () => { }) }) - // Covers updates driven by setQuery. describe('Updating query state', () => { it('should update keywords when setQuery receives keywords', () => { const { result } = renderWithAdapter() @@ -126,7 +114,6 @@ describe('useAppsQueryState', () => { }) }) - // Covers URL updates triggered by query changes. describe('URL synchronization', () => { it('should sync keywords to URL when keywords change', async () => { const { result, onUrlUpdate } = renderWithAdapter() @@ -202,7 +189,6 @@ describe('useAppsQueryState', () => { }) }) - // Covers decoding and empty values. describe('Edge cases', () => { it('should treat empty tagIDs as empty list when URL param is empty', () => { const { result } = renderWithAdapter('?tagIDs=') @@ -223,7 +209,6 @@ describe('useAppsQueryState', () => { }) }) - // Covers multi-step updates that mimic real usage. describe('Integration scenarios', () => { it('should keep accumulated filters when updates are sequential', () => { const { result } = renderWithAdapter() diff --git a/web/app/components/apps/hooks/use-dsl-drag-drop.spec.ts b/web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts similarity index 94% rename from web/app/components/apps/hooks/use-dsl-drag-drop.spec.ts rename to web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts index f1b186973c..58fed4caa8 100644 --- a/web/app/components/apps/hooks/use-dsl-drag-drop.spec.ts +++ b/web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts @@ -1,15 +1,6 @@ -/** - * Test suite for useDSLDragDrop hook - * - * This hook provides drag-and-drop functionality for DSL files, enabling: - * - File drag detection with visual feedback (dragging state) - * - YAML/YML file filtering (only accepts .yaml and .yml files) - * - Enable/disable toggle for conditional drag-and-drop - * - Cleanup on unmount (removes event listeners) - */ import type { Mock } from 'vitest' import { act, renderHook } from '@testing-library/react' -import { useDSLDragDrop } from './use-dsl-drag-drop' +import { useDSLDragDrop } from '../use-dsl-drag-drop' describe('useDSLDragDrop', () => { let container: HTMLDivElement @@ -26,7 +17,6 @@ describe('useDSLDragDrop', () => { document.body.removeChild(container) }) - // Helper to create drag events const createDragEvent = (type: string, files: File[] = []) => { const dataTransfer = { types: files.length > 0 ? ['Files'] : [], @@ -50,7 +40,6 @@ describe('useDSLDragDrop', () => { return event } - // Helper to create a mock file const createMockFile = (name: string) => { return new File(['content'], name, { type: 'application/x-yaml' }) } @@ -147,14 +136,12 @@ describe('useDSLDragDrop', () => { }), ) - // First, enter with files const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')]) act(() => { container.dispatchEvent(enterEvent) }) expect(result.current.dragging).toBe(true) - // Then leave with null relatedTarget (leaving container) const leaveEvent = createDragEvent('dragleave') Object.defineProperty(leaveEvent, 'relatedTarget', { value: null, @@ -180,14 +167,12 @@ describe('useDSLDragDrop', () => { }), ) - // First, enter with files const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')]) act(() => { container.dispatchEvent(enterEvent) }) expect(result.current.dragging).toBe(true) - // Then leave but to a child element const leaveEvent = createDragEvent('dragleave') Object.defineProperty(leaveEvent, 'relatedTarget', { value: childElement, @@ -290,14 +275,12 @@ describe('useDSLDragDrop', () => { }), ) - // First, enter with files const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')]) act(() => { container.dispatchEvent(enterEvent) }) expect(result.current.dragging).toBe(true) - // Then drop const dropEvent = createDragEvent('drop', [createMockFile('test.yaml')]) act(() => { container.dispatchEvent(dropEvent) @@ -409,14 +392,12 @@ describe('useDSLDragDrop', () => { { initialProps: { enabled: true } }, ) - // Set dragging state const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')]) act(() => { container.dispatchEvent(enterEvent) }) expect(result.current.dragging).toBe(true) - // Disable the hook rerender({ enabled: false }) expect(result.current.dragging).toBe(false) }) diff --git a/web/app/components/develop/__tests__/code.spec.tsx b/web/app/components/develop/__tests__/code.spec.tsx index 2614be704d..452e6ea98f 100644 --- a/web/app/components/develop/__tests__/code.spec.tsx +++ b/web/app/components/develop/__tests__/code.spec.tsx @@ -6,12 +6,10 @@ vi.mock('@/utils/clipboard', () => ({ writeTextToClipboard: vi.fn().mockResolvedValue(undefined), })) -// Suppress expected React act() warnings and jsdom unimplemented API errors -vi.spyOn(console, 'error').mockImplementation(() => {}) - describe('code.tsx components', () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) vi.useFakeTimers({ shouldAdvanceTime: true }) // jsdom does not implement scrollBy; mock it to prevent stderr noise window.scrollBy = vi.fn() @@ -20,6 +18,7 @@ describe('code.tsx components', () => { afterEach(() => { vi.runOnlyPendingTimers() vi.useRealTimers() + vi.restoreAllMocks() }) describe('Code', () => { diff --git a/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx index 36a577c98a..9a9d5c3345 100644 --- a/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx @@ -2,9 +2,6 @@ import { act, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import InputCopy from '../input-copy' -// Suppress expected React act() warnings from CopyFeedback timer-based state updates -vi.spyOn(console, 'error').mockImplementation(() => {}) - async function renderAndFlush(ui: React.ReactElement) { const result = render(ui) await act(async () => { @@ -18,6 +15,7 @@ const execCommandMock = vi.fn().mockReturnValue(true) describe('InputCopy', () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) vi.useFakeTimers({ shouldAdvanceTime: true }) execCommandMock.mockReturnValue(true) document.execCommand = execCommandMock @@ -26,6 +24,7 @@ describe('InputCopy', () => { afterEach(() => { vi.runOnlyPendingTimers() vi.useRealTimers() + vi.restoreAllMocks() }) describe('rendering', () => { diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx index a5c6d4be99..9b15e75b9d 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx @@ -3,9 +3,6 @@ import userEvent from '@testing-library/user-event' import { afterEach } from 'vitest' import SecretKeyModal from '../secret-key-modal' -// Suppress expected React act() warnings from Headless UI Dialog transitions and async API state updates -vi.spyOn(console, 'error').mockImplementation(() => {}) - async function renderModal(ui: React.ReactElement) { const result = render(ui) await act(async () => { @@ -91,6 +88,8 @@ describe('SecretKeyModal', () => { beforeEach(() => { vi.clearAllMocks() + // Suppress expected React act() warnings from Headless UI Dialog transitions and async API state updates + vi.spyOn(console, 'error').mockImplementation(() => {}) vi.useFakeTimers({ shouldAdvanceTime: true }) mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' }) mockIsCurrentWorkspaceManager.mockReturnValue(true) @@ -104,6 +103,7 @@ describe('SecretKeyModal', () => { afterEach(() => { vi.runOnlyPendingTimers() vi.useRealTimers() + vi.restoreAllMocks() }) describe('rendering when shown', () => { diff --git a/web/app/components/explore/try-app/__tests__/index.spec.tsx b/web/app/components/explore/try-app/__tests__/index.spec.tsx index e46155a217..be6c6ea8e2 100644 --- a/web/app/components/explore/try-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx @@ -4,9 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import TryApp from '../index' import { TypeEnum } from '../tab' -// Suppress expected React act() warnings from internal async state updates -vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() as object return { @@ -91,6 +88,9 @@ const createMockAppDetail = (mode: string = 'chat'): TryAppInfo => ({ describe('TryApp (main index.tsx)', () => { beforeEach(() => { + vi.clearAllMocks() + // Suppress expected React act() warnings from internal async state updates + vi.spyOn(console, 'error').mockImplementation(() => {}) mockUseGetTryAppInfo.mockReturnValue({ data: createMockAppDetail(), isLoading: false, @@ -99,7 +99,7 @@ describe('TryApp (main index.tsx)', () => { afterEach(() => { cleanup() - vi.clearAllMocks() + vi.restoreAllMocks() }) describe('loading state', () => { diff --git a/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx index f29d93658c..9ff1de49ad 100644 --- a/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx @@ -23,6 +23,10 @@ describe('VersionMismatchModal', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) }) + afterEach(() => { + vi.restoreAllMocks() + }) + describe('rendering', () => { it('should render dialog when isShow is true', () => { render(<VersionMismatchModal {...defaultProps} />) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts index 9707ad0702..23b1065a45 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts @@ -83,7 +83,7 @@ describe('usePipelineInit', () => { }) afterEach(() => { - vi.clearAllMocks() + vi.restoreAllMocks() }) describe('hook initialization', () => { From 98466e2d29ac5146b6a3220aa5f16cded77ce30b Mon Sep 17 00:00:00 2001 From: Saumya Talwani <68903741+saumyatalwani@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:59:04 +0530 Subject: [PATCH 18/18] test: add tests for some base components (#32265) --- .../base/agent-log-modal/detail.spec.tsx | 260 ++++++++++++++++++ .../base/agent-log-modal/detail.tsx | 2 + .../base/agent-log-modal/index.spec.tsx | 142 ++++++++++ .../base/agent-log-modal/iteration.spec.tsx | 57 ++++ .../base/agent-log-modal/result.spec.tsx | 85 ++++++ .../base/agent-log-modal/tool-call.spec.tsx | 126 +++++++++ .../base/agent-log-modal/tracing.spec.tsx | 50 ++++ .../base/checkbox-list/index.spec.tsx | 195 +++++++++++++ .../components/base/checkbox-list/index.tsx | 14 +- .../components/base/confirm/index.spec.tsx | 117 ++++++++ web/app/components/base/confirm/index.tsx | 1 + .../base/copy-feedback/index.spec.tsx | 93 +++++++ .../base/emoji-picker/Inner.spec.tsx | 169 ++++++++++++ .../components/base/emoji-picker/Inner.tsx | 16 +- .../base/emoji-picker/index.spec.tsx | 115 ++++++++ .../base/file-thumb/image-render.spec.tsx | 20 ++ .../components/base/file-thumb/index.spec.tsx | 74 +++++ .../base/linked-apps-panel/index.spec.tsx | 93 +++++++ .../base/list-empty/horizontal-line.spec.tsx | 33 +++ .../components/base/list-empty/index.spec.tsx | 37 +++ .../base/list-empty/vertical-line.spec.tsx | 33 +++ .../components/base/logo/dify-logo.spec.tsx | 94 +++++++ .../logo/logo-embedded-chat-avatar.spec.tsx | 32 +++ .../logo/logo-embedded-chat-header.spec.tsx | 29 ++ .../components/base/logo/logo-site.spec.tsx | 22 ++ .../base/search-input/index.spec.tsx | 91 ++++++ web/eslint-suppressions.json | 8 - 27 files changed, 1985 insertions(+), 23 deletions(-) create mode 100644 web/app/components/base/agent-log-modal/detail.spec.tsx create mode 100644 web/app/components/base/agent-log-modal/index.spec.tsx create mode 100644 web/app/components/base/agent-log-modal/iteration.spec.tsx create mode 100644 web/app/components/base/agent-log-modal/result.spec.tsx create mode 100644 web/app/components/base/agent-log-modal/tool-call.spec.tsx create mode 100644 web/app/components/base/agent-log-modal/tracing.spec.tsx create mode 100644 web/app/components/base/checkbox-list/index.spec.tsx create mode 100644 web/app/components/base/confirm/index.spec.tsx create mode 100644 web/app/components/base/copy-feedback/index.spec.tsx create mode 100644 web/app/components/base/emoji-picker/Inner.spec.tsx create mode 100644 web/app/components/base/emoji-picker/index.spec.tsx create mode 100644 web/app/components/base/file-thumb/image-render.spec.tsx create mode 100644 web/app/components/base/file-thumb/index.spec.tsx create mode 100644 web/app/components/base/linked-apps-panel/index.spec.tsx create mode 100644 web/app/components/base/list-empty/horizontal-line.spec.tsx create mode 100644 web/app/components/base/list-empty/index.spec.tsx create mode 100644 web/app/components/base/list-empty/vertical-line.spec.tsx create mode 100644 web/app/components/base/logo/dify-logo.spec.tsx create mode 100644 web/app/components/base/logo/logo-embedded-chat-avatar.spec.tsx create mode 100644 web/app/components/base/logo/logo-embedded-chat-header.spec.tsx create mode 100644 web/app/components/base/logo/logo-site.spec.tsx create mode 100644 web/app/components/base/search-input/index.spec.tsx diff --git a/web/app/components/base/agent-log-modal/detail.spec.tsx b/web/app/components/base/agent-log-modal/detail.spec.tsx new file mode 100644 index 0000000000..dd663ac892 --- /dev/null +++ b/web/app/components/base/agent-log-modal/detail.spec.tsx @@ -0,0 +1,260 @@ +import type { ComponentProps } from 'react' +import type { IChatItem } from '@/app/components/base/chat/chat/type' +import type { AgentLogDetailResponse } from '@/models/log' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { ToastContext } from '@/app/components/base/toast' +import { fetchAgentLogDetail } from '@/service/log' +import AgentLogDetail from './detail' + +vi.mock('@/service/log', () => ({ + fetchAgentLogDetail: vi.fn(), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: vi.fn(selector => selector({ appDetail: { id: 'app-id' } })), +})) + +vi.mock('@/app/components/workflow/run/status', () => ({ + default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => ( + <div data-testid="status-panel" data-status={String(status)} data-time={String(time)} data-tokens={String(tokens)}>{error ? <span>{String(error)}</span> : null}</div> + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + <div data-testid="code-editor"> + {title} + {typeof value === 'string' ? value : JSON.stringify(value)} + </div> + ), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ formatTime: (ts: number, fmt: string) => `${ts}-${fmt}` }), +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () => <div data-testid="block-icon" />, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ + ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />, +})) + +const createMockLog = (overrides: Partial<IChatItem> = {}): IChatItem => ({ + id: 'msg-id', + content: 'output content', + isAnswer: false, + conversationId: 'conv-id', + input: 'user input', + ...overrides, +}) + +const createMockResponse = (overrides: Partial<AgentLogDetailResponse> = {}): AgentLogDetailResponse => ({ + meta: { + status: 'succeeded', + executor: 'User', + start_time: '2023-01-01', + elapsed_time: 1.0, + total_tokens: 100, + agent_mode: 'function_call', + iterations: 1, + }, + iterations: [ + { + created_at: '', + files: [], + thought: '', + tokens: 0, + tool_raw: { inputs: '', outputs: '' }, + tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }], + }, + ], + files: [], + ...overrides, +}) + +describe('AgentLogDetail', () => { + const notify = vi.fn() + + const renderComponent = (props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) => { + const defaultProps: ComponentProps<typeof AgentLogDetail> = { + conversationID: 'conv-id', + messageID: 'msg-id', + log: createMockLog(), + } + return render( + <ToastContext.Provider value={{ notify, close: vi.fn() } as ComponentProps<typeof ToastContext.Provider>['value']}> + <AgentLogDetail {...defaultProps} {...props} /> + </ToastContext.Provider>, + ) + } + + const renderAndWaitForData = async (props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) => { + const result = renderComponent(props) + await waitFor(() => { + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + return result + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should show loading indicator while fetching data', async () => { + vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + + renderComponent() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should display result panel after data loads', async () => { + vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + + await renderAndWaitForData() + + expect(screen.getByText(/runLog.detail/i)).toBeInTheDocument() + expect(screen.getByText(/runLog.tracing/i)).toBeInTheDocument() + }) + + it('should call fetchAgentLogDetail with correct params', async () => { + vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + + await renderAndWaitForData() + + expect(fetchAgentLogDetail).toHaveBeenCalledWith({ + appID: 'app-id', + params: { + conversation_id: 'conv-id', + message_id: 'msg-id', + }, + }) + }) + }) + + describe('Props', () => { + it('should default to DETAIL tab when activeTab is not provided', async () => { + vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + + await renderAndWaitForData() + + const detailTab = screen.getByText(/runLog.detail/i) + expect(detailTab.getAttribute('data-active')).toBe('true') + }) + + it('should show TRACING tab when activeTab is TRACING', async () => { + vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + + await renderAndWaitForData({ activeTab: 'TRACING' }) + + const tracingTab = screen.getByText(/runLog.tracing/i) + expect(tracingTab.getAttribute('data-active')).toBe('true') + }) + }) + + describe('User Interactions', () => { + it('should switch to TRACING tab when clicked', async () => { + vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + + await renderAndWaitForData() + + fireEvent.click(screen.getByText(/runLog.tracing/i)) + + await waitFor(() => { + const tracingTab = screen.getByText(/runLog.tracing/i) + expect(tracingTab.getAttribute('data-active')).toBe('true') + }) + + const detailTab = screen.getByText(/runLog.detail/i) + expect(detailTab.getAttribute('data-active')).toBe('false') + }) + + it('should switch back to DETAIL tab after switching to TRACING', async () => { + vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + + await renderAndWaitForData() + + fireEvent.click(screen.getByText(/runLog.tracing/i)) + + await waitFor(() => { + expect(screen.getByText(/runLog.tracing/i).getAttribute('data-active')).toBe('true') + }) + + fireEvent.click(screen.getByText(/runLog.detail/i)) + + await waitFor(() => { + const detailTab = screen.getByText(/runLog.detail/i) + expect(detailTab.getAttribute('data-active')).toBe('true') + }) + }) + }) + + describe('Edge Cases', () => { + it('should notify on API error', async () => { + vi.mocked(fetchAgentLogDetail).mockRejectedValue(new Error('API Error')) + + renderComponent() + + await waitFor(() => { + expect(notify).toHaveBeenCalledWith({ + type: 'error', + message: 'Error: API Error', + }) + }) + }) + + it('should stop loading after API error', async () => { + vi.mocked(fetchAgentLogDetail).mockRejectedValue(new Error('Network failure')) + + renderComponent() + + await waitFor(() => { + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + }) + + it('should handle response with empty iterations', async () => { + vi.mocked(fetchAgentLogDetail).mockResolvedValue( + createMockResponse({ iterations: [] }), + ) + + await renderAndWaitForData() + }) + + it('should handle response with multiple iterations and duplicate tools', async () => { + const response = createMockResponse({ + iterations: [ + { + created_at: '', + files: [], + thought: '', + tokens: 0, + tool_raw: { inputs: '', outputs: '' }, + tool_calls: [ + { tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }, + { tool_name: 'tool2', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 2' } }, + ], + }, + { + created_at: '', + files: [], + thought: '', + tokens: 0, + tool_raw: { inputs: '', outputs: '' }, + tool_calls: [ + { tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }, + ], + }, + ], + }) + vi.mocked(fetchAgentLogDetail).mockResolvedValue(response) + + await renderAndWaitForData() + + expect(screen.getByText(/runLog.detail/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx index a82a3207b1..36b502e9a5 100644 --- a/web/app/components/base/agent-log-modal/detail.tsx +++ b/web/app/components/base/agent-log-modal/detail.tsx @@ -89,6 +89,7 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({ 'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary', currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-text-secondary', )} + data-active={currentTab === 'DETAIL'} onClick={() => switchTab('DETAIL')} > {t('detail', { ns: 'runLog' })} @@ -98,6 +99,7 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({ 'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary', currentTab === 'TRACING' && '!border-[rgb(21,94,239)] text-text-secondary', )} + data-active={currentTab === 'TRACING'} onClick={() => switchTab('TRACING')} > {t('tracing', { ns: 'runLog' })} diff --git a/web/app/components/base/agent-log-modal/index.spec.tsx b/web/app/components/base/agent-log-modal/index.spec.tsx new file mode 100644 index 0000000000..17c9bc8cf1 --- /dev/null +++ b/web/app/components/base/agent-log-modal/index.spec.tsx @@ -0,0 +1,142 @@ +import type { IChatItem } from '@/app/components/base/chat/chat/type' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useClickAway } from 'ahooks' +import { ToastContext } from '@/app/components/base/toast' +import { fetchAgentLogDetail } from '@/service/log' +import AgentLogModal from './index' + +vi.mock('@/service/log', () => ({ + fetchAgentLogDetail: vi.fn(), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: vi.fn(selector => selector({ appDetail: { id: 'app-id' } })), +})) + +vi.mock('@/app/components/workflow/run/status', () => ({ + default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => ( + <div data-testid="status-panel" data-status={String(status)} data-time={String(time)} data-tokens={String(tokens)}>{error ? <span>{String(error)}</span> : null}</div> + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + <div data-testid="code-editor"> + {title} + {typeof value === 'string' ? value : JSON.stringify(value)} + </div> + ), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ formatTime: (ts: number, fmt: string) => `${ts}-${fmt}` }), +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () => <div data-testid="block-icon" />, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ + ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />, +})) + +vi.mock('ahooks', () => ({ + useClickAway: vi.fn(), +})) + +const mockLog = { + id: 'msg-id', + conversationId: 'conv-id', + content: 'content', + isAnswer: false, + input: 'test input', +} as IChatItem + +const mockProps = { + currentLogItem: mockLog, + width: 1000, + onCancel: vi.fn(), +} + +describe('AgentLogModal', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(fetchAgentLogDetail).mockResolvedValue({ + meta: { + status: 'succeeded', + executor: 'User', + start_time: '2023-01-01', + elapsed_time: 1.0, + total_tokens: 100, + agent_mode: 'function_call', + iterations: 1, + }, + iterations: [{ + created_at: '', + files: [], + thought: '', + tokens: 0, + tool_raw: { inputs: '', outputs: '' }, + tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }], + }], + files: [], + }) + }) + + it('should return null if no currentLogItem', () => { + const { container } = render(<AgentLogModal {...mockProps} currentLogItem={undefined} />) + expect(container.firstChild).toBeNull() + }) + + it('should return null if no conversationId', () => { + const { container } = render(<AgentLogModal {...mockProps} currentLogItem={{ id: '1' } as unknown as IChatItem} />) + expect(container.firstChild).toBeNull() + }) + + it('should render correctly when log item is provided', async () => { + render( + <ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}> + <AgentLogModal {...mockProps} /> + </ToastContext.Provider>, + ) + + expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByText(/runLog.detail/i)).toBeInTheDocument() + }) + }) + + it('should call onCancel when close button is clicked', () => { + vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + + render( + <ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}> + <AgentLogModal {...mockProps} /> + </ToastContext.Provider>, + ) + + const closeBtn = screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling! + fireEvent.click(closeBtn) + + expect(mockProps.onCancel).toHaveBeenCalledTimes(1) + }) + + it('should call onCancel when clicking away', () => { + vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + + let clickAwayHandler!: (event: Event) => void + vi.mocked(useClickAway).mockImplementation((callback) => { + clickAwayHandler = callback + }) + + render( + <ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}> + <AgentLogModal {...mockProps} /> + </ToastContext.Provider>, + ) + clickAwayHandler(new Event('click')) + + expect(mockProps.onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/base/agent-log-modal/iteration.spec.tsx b/web/app/components/base/agent-log-modal/iteration.spec.tsx new file mode 100644 index 0000000000..15d5b815fb --- /dev/null +++ b/web/app/components/base/agent-log-modal/iteration.spec.tsx @@ -0,0 +1,57 @@ +import type { AgentIteration } from '@/models/log' +import { render, screen } from '@testing-library/react' +import Iteration from './iteration' + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + <div data-testid="code-editor"> + <div data-testid="code-editor-title">{title}</div> + <div data-testid="code-editor-value">{JSON.stringify(value)}</div> + </div> + ), +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () => <div data-testid="block-icon" />, +})) + +const mockIterationInfo: AgentIteration = { + created_at: '2023-01-01', + files: [], + thought: 'Test thought', + tokens: 100, + tool_calls: [ + { + status: 'success', + tool_name: 'test_tool', + tool_label: { en: 'Test Tool' }, + tool_icon: null, + }, + ], + tool_raw: { + inputs: '{}', + outputs: 'test output', + }, +} + +describe('Iteration', () => { + it('should render final processing when isFinal is true', () => { + render(<Iteration iterationInfo={mockIterationInfo} isFinal={true} index={1} />) + + expect(screen.getByText(/appLog.agentLogDetail.finalProcessing/i)).toBeInTheDocument() + expect(screen.queryByText(/appLog.agentLogDetail.iteration/i)).not.toBeInTheDocument() + }) + + it('should render iteration index when isFinal is false', () => { + render(<Iteration iterationInfo={mockIterationInfo} isFinal={false} index={2} />) + + expect(screen.getByText(/APPLOG.AGENTLOGDETAIL.ITERATION 2/i)).toBeInTheDocument() + expect(screen.queryByText(/appLog.agentLogDetail.finalProcessing/i)).not.toBeInTheDocument() + }) + + it('should render LLM tool call and subsequent tool calls', () => { + render(<Iteration iterationInfo={mockIterationInfo} isFinal={false} index={1} />) + expect(screen.getByTitle('LLM')).toBeInTheDocument() + expect(screen.getByText('Test Tool')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/agent-log-modal/result.spec.tsx b/web/app/components/base/agent-log-modal/result.spec.tsx new file mode 100644 index 0000000000..846d433cab --- /dev/null +++ b/web/app/components/base/agent-log-modal/result.spec.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import ResultPanel from './result' + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + <div data-testid="code-editor"> + <div data-testid="code-editor-title">{title}</div> + <div data-testid="code-editor-value">{JSON.stringify(value)}</div> + </div> + ), +})) + +vi.mock('@/app/components/workflow/run/status', () => ({ + default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => ( + <div data-testid="status-panel"> + <span>{status}</span> + <span>{time}</span> + <span>{tokens}</span> + <span>{error}</span> + </div> + ), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: vi.fn((ts, _format) => `formatted-${ts}`), + }), +})) + +const mockProps = { + status: 'succeeded', + elapsed_time: 1.23456, + total_tokens: 150, + error: '', + inputs: { query: 'input' }, + outputs: { answer: 'output' }, + created_by: 'User Name', + created_at: '2023-01-01T00:00:00Z', + agentMode: 'function_call', + tools: ['tool1', 'tool2'], + iterations: 3, +} + +describe('ResultPanel', () => { + it('should render status panel and code editors', () => { + render(<ResultPanel {...mockProps} />) + + expect(screen.getByTestId('status-panel')).toBeInTheDocument() + + const editors = screen.getAllByTestId('code-editor') + expect(editors).toHaveLength(2) + + expect(screen.getByText('INPUT')).toBeInTheDocument() + expect(screen.getByText('OUTPUT')).toBeInTheDocument() + expect(screen.getByText(JSON.stringify(mockProps.inputs))).toBeInTheDocument() + expect(screen.getByText(JSON.stringify(mockProps.outputs))).toBeInTheDocument() + }) + + it('should display correct metadata', () => { + render(<ResultPanel {...mockProps} />) + + expect(screen.getByText('User Name')).toBeInTheDocument() + expect(screen.getByText('1.235s')).toBeInTheDocument() // toFixed(3) + expect(screen.getByText('150 Tokens')).toBeInTheDocument() + expect(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeInTheDocument() + expect(screen.getByText('tool1, tool2')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + + // Check formatted time + expect(screen.getByText(/formatted-/)).toBeInTheDocument() + }) + + it('should handle missing created_by and tools', () => { + render(<ResultPanel {...mockProps} created_by={undefined} tools={[]} />) + + expect(screen.getByText('N/A')).toBeInTheDocument() + expect(screen.getByText('Null')).toBeInTheDocument() + }) + + it('should display ReACT mode correctly', () => { + render(<ResultPanel {...mockProps} agentMode="react" />) + expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/agent-log-modal/tool-call.spec.tsx b/web/app/components/base/agent-log-modal/tool-call.spec.tsx new file mode 100644 index 0000000000..496049a8a8 --- /dev/null +++ b/web/app/components/base/agent-log-modal/tool-call.spec.tsx @@ -0,0 +1,126 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { BlockEnum } from '@/app/components/workflow/types' +import ToolCallItem from './tool-call' + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + <div data-testid="code-editor"> + <div data-testid="code-editor-title">{title}</div> + <div data-testid="code-editor-value">{JSON.stringify(value)}</div> + </div> + ), +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: ({ type }: { type: BlockEnum }) => <div data-testid="block-icon" data-type={type} />, +})) + +const mockToolCall = { + status: 'success', + error: null, + tool_name: 'test_tool', + tool_label: { en: 'Test Tool Label' }, + tool_icon: 'icon', + time_cost: 1.5, + tool_input: { query: 'hello' }, + tool_output: { result: 'world' }, +} + +describe('ToolCallItem', () => { + it('should render tool name correctly for LLM', () => { + render(<ToolCallItem toolCall={mockToolCall} isLLM={true} />) + expect(screen.getByText('LLM')).toBeInTheDocument() + expect(screen.getByTestId('block-icon')).toHaveAttribute('data-type', BlockEnum.LLM) + }) + + it('should render tool name from label for non-LLM', () => { + render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />) + expect(screen.getByText('Test Tool Label')).toBeInTheDocument() + expect(screen.getByTestId('block-icon')).toHaveAttribute('data-type', BlockEnum.Tool) + }) + + it('should format time correctly', () => { + render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />) + expect(screen.getByText('1.500 s')).toBeInTheDocument() + + // Test ms format + render(<ToolCallItem toolCall={{ ...mockToolCall, time_cost: 0.5 }} isLLM={false} />) + expect(screen.getByText('500.000 ms')).toBeInTheDocument() + + // Test minute format + render(<ToolCallItem toolCall={{ ...mockToolCall, time_cost: 65 }} isLLM={false} />) + expect(screen.getByText('1 m 5.000 s')).toBeInTheDocument() + }) + + it('should format token count correctly', () => { + render(<ToolCallItem toolCall={mockToolCall} isLLM={true} tokens={1200} />) + expect(screen.getByText('1.2K tokens')).toBeInTheDocument() + + render(<ToolCallItem toolCall={mockToolCall} isLLM={true} tokens={800} />) + expect(screen.getByText('800 tokens')).toBeInTheDocument() + + render(<ToolCallItem toolCall={mockToolCall} isLLM={true} tokens={1200000} />) + expect(screen.getByText('1.2M tokens')).toBeInTheDocument() + }) + + it('should handle collapse/expand', () => { + render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />) + + expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText(/Test Tool Label/i)) + expect(screen.getAllByTestId('code-editor')).toHaveLength(2) + }) + + it('should display error message when status is error', () => { + const errorToolCall = { + ...mockToolCall, + status: 'error', + error: 'Something went wrong', + } + render(<ToolCallItem toolCall={errorToolCall} isLLM={false} />) + + fireEvent.click(screen.getByText(/Test Tool Label/i)) + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('should display LLM specific fields when expanded', () => { + render( + <ToolCallItem + toolCall={mockToolCall} + isLLM={true} + observation="test observation" + finalAnswer="test final answer" + isFinal={true} + />, + ) + + fireEvent.click(screen.getByText('LLM')) + + const titles = screen.getAllByTestId('code-editor-title') + const titleTexts = titles.map(t => t.textContent) + + expect(titleTexts).toContain('INPUT') + expect(titleTexts).toContain('OUTPUT') + expect(titleTexts).toContain('OBSERVATION') + expect(titleTexts).toContain('FINAL ANSWER') + }) + + it('should display THOUGHT instead of FINAL ANSWER when isFinal is false', () => { + render( + <ToolCallItem + toolCall={mockToolCall} + isLLM={true} + observation="test observation" + finalAnswer="test thought" + isFinal={false} + />, + ) + + fireEvent.click(screen.getByText('LLM')) + expect(screen.getByText('THOUGHT')).toBeInTheDocument() + expect(screen.queryByText('FINAL ANSWER')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/agent-log-modal/tracing.spec.tsx b/web/app/components/base/agent-log-modal/tracing.spec.tsx new file mode 100644 index 0000000000..e0f4a81f99 --- /dev/null +++ b/web/app/components/base/agent-log-modal/tracing.spec.tsx @@ -0,0 +1,50 @@ +import type { AgentIteration } from '@/models/log' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import TracingPanel from './tracing' + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () => <div data-testid="block-icon" />, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ + ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( + <div data-testid="code-editor"> + {title} + {typeof value === 'string' ? value : JSON.stringify(value)} + </div> + ), +})) + +const createIteration = (thought: string, tokens: number): AgentIteration => ({ + created_at: '', + files: [], + thought, + tokens, + tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }], + tool_raw: { inputs: '', outputs: '' }, +}) + +const mockList: AgentIteration[] = [ + createIteration('Thought 1', 10), + createIteration('Thought 2', 20), + createIteration('Thought 3', 30), +] + +describe('TracingPanel', () => { + it('should render all iterations in the list', () => { + render(<TracingPanel list={mockList} />) + + expect(screen.getByText(/finalProcessing/i)).toBeInTheDocument() + expect(screen.getAllByText(/ITERATION/i).length).toBe(2) + }) + + it('should render empty list correctly', () => { + const { container } = render(<TracingPanel list={[]} />) + expect(container.querySelector('.bg-background-section')?.children.length).toBe(0) + }) +}) diff --git a/web/app/components/base/checkbox-list/index.spec.tsx b/web/app/components/base/checkbox-list/index.spec.tsx new file mode 100644 index 0000000000..59ddfb69fc --- /dev/null +++ b/web/app/components/base/checkbox-list/index.spec.tsx @@ -0,0 +1,195 @@ +/* eslint-disable next/no-img-element */ +import type { ImgHTMLAttributes } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CheckboxList from '.' + +vi.mock('next/image', () => ({ + default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />, +})) + +describe('checkbox list component', () => { + const options = [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3', value: 'option3' }, + { label: 'Apple', value: 'apple' }, + ] + + it('renders with title, description and options', () => { + render( + <CheckboxList + title="Test Title" + description="Test Description" + options={options} + />, + ) + expect(screen.getByText('Test Title')).toBeInTheDocument() + expect(screen.getByText('Test Description')).toBeInTheDocument() + options.forEach((option) => { + expect(screen.getByText(option.label)).toBeInTheDocument() + }) + }) + + it('filters options by label', async () => { + render(<CheckboxList options={options} />) + + const input = screen.getByRole('textbox') + await userEvent.type(input, 'app') + + expect(screen.getByText('Apple')).toBeInTheDocument() + expect(screen.queryByText('Option 2')).not.toBeInTheDocument() + expect(screen.queryByText('Option 3')).not.toBeInTheDocument() + }) + + it('renders select-all checkbox', () => { + render(<CheckboxList options={options} showSelectAll />) + const checkboxes = screen.getByTestId('checkbox-selectAll') + expect(checkboxes).toBeInTheDocument() + }) + + it('selects all options when select-all is clicked', async () => { + const onChange = vi.fn() + + render( + <CheckboxList + options={options} + value={[]} + onChange={onChange} + showSelectAll + />, + ) + + const selectAll = screen.getByTestId('checkbox-selectAll') + await userEvent.click(selectAll) + + expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple']) + }) + + it('does not select all options when select-all is clicked when disabled', async () => { + const onChange = vi.fn() + + render( + <CheckboxList + options={options} + value={[]} + disabled + showSelectAll + onChange={onChange} + />, + ) + + const selectAll = screen.getByTestId('checkbox-selectAll') + await userEvent.click(selectAll) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('deselects all options when select-all is clicked', async () => { + const onChange = vi.fn() + + render( + <CheckboxList + options={options} + value={['option1', 'option2', 'option3', 'apple']} + onChange={onChange} + showSelectAll + />, + ) + + const selectAll = screen.getByTestId('checkbox-selectAll') + await userEvent.click(selectAll) + + expect(onChange).toHaveBeenCalledWith([]) + }) + + it('selects select-all when all options are clicked', async () => { + const onChange = vi.fn() + + render( + <CheckboxList + options={options} + value={['option1', 'option2', 'option3', 'apple']} + onChange={onChange} + showSelectAll + />, + ) + + const selectAll = screen.getByTestId('checkbox-selectAll') + expect(selectAll.querySelector('[data-testid="check-icon-selectAll"]')).toBeInTheDocument() + }) + + it('hides select-all checkbox when searching', async () => { + render(<CheckboxList options={options} />) + await userEvent.type(screen.getByRole('textbox'), 'app') + expect(screen.queryByTestId('checkbox-selectAll')).not.toBeInTheDocument() + }) + + it('selects options when checkbox is clicked', async () => { + const onChange = vi.fn() + + render( + <CheckboxList + options={options} + value={[]} + onChange={onChange} + showSelectAll={false} + />, + ) + + const selectOption = screen.getByTestId('checkbox-option1') + await userEvent.click(selectOption) + expect(onChange).toHaveBeenCalledWith(['option1']) + }) + + it('deselects options when checkbox is clicked when selected', async () => { + const onChange = vi.fn() + + render( + <CheckboxList + options={options} + value={['option1']} + onChange={onChange} + showSelectAll={false} + />, + ) + + const selectOption = screen.getByTestId('checkbox-option1') + await userEvent.click(selectOption) + expect(onChange).toHaveBeenCalledWith([]) + }) + + it('does not select options when checkbox is clicked', async () => { + const onChange = vi.fn() + + render( + <CheckboxList + options={options} + value={[]} + onChange={onChange} + disabled + />, + ) + + const selectOption = screen.getByTestId('checkbox-option1') + await userEvent.click(selectOption) + expect(onChange).not.toHaveBeenCalled() + }) + + it('Reset button works', async () => { + const onChange = vi.fn() + + render( + <CheckboxList + options={options} + value={[]} + onChange={onChange} + />, + ) + + const input = screen.getByRole('textbox') + await userEvent.type(input, 'ban') + await userEvent.click(screen.getByText('common.operation.resetKeywords')) + expect(input).toHaveValue('') + }) +}) diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx index 9200724e79..b83f46960b 100644 --- a/web/app/components/base/checkbox-list/index.tsx +++ b/web/app/components/base/checkbox-list/index.tsx @@ -101,12 +101,12 @@ const CheckboxList: FC<CheckboxListProps> = ({ return ( <div className={cn('flex w-full flex-col gap-1', containerClassName)}> {label && ( - <div className="system-sm-medium text-text-secondary"> + <div className="text-text-secondary system-sm-medium"> {label} </div> )} {description && ( - <div className="body-xs-regular text-text-tertiary"> + <div className="text-text-tertiary body-xs-regular"> {description} </div> )} @@ -120,13 +120,14 @@ const CheckboxList: FC<CheckboxListProps> = ({ indeterminate={isIndeterminate} onCheck={handleSelectAll} disabled={disabled} + id="selectAll" /> )} {!searchQuery ? ( <div className="flex min-w-0 flex-1 items-center gap-1"> {title && ( - <span className="system-xs-semibold-uppercase truncate leading-5 text-text-secondary"> + <span className="truncate leading-5 text-text-secondary system-xs-semibold-uppercase"> {title} </span> )} @@ -138,7 +139,7 @@ const CheckboxList: FC<CheckboxListProps> = ({ </div> ) : ( - <div className="system-sm-medium-uppercase flex-1 leading-6 text-text-secondary"> + <div className="flex-1 leading-6 text-text-secondary system-sm-medium-uppercase"> { filteredOptions.length > 0 ? t('operation.searchCount', { ns: 'common', count: filteredOptions.length, content: title }) @@ -168,7 +169,7 @@ const CheckboxList: FC<CheckboxListProps> = ({ ? ( <div className="flex flex-col items-center justify-center gap-2"> <Image alt="search menu" src={SearchMenu} width={32} /> - <span className="system-sm-regular text-text-secondary">{t('operation.noSearchResults', { ns: 'common', content: title })}</span> + <span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span> <Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button> </div> ) @@ -198,9 +199,10 @@ const CheckboxList: FC<CheckboxListProps> = ({ handleToggleOption(option.value) }} disabled={option.disabled || disabled} + id={option.value} /> <div - className="system-sm-medium flex-1 truncate text-text-secondary" + className="flex-1 truncate text-text-secondary system-sm-medium" title={option.label} > {option.label} diff --git a/web/app/components/base/confirm/index.spec.tsx b/web/app/components/base/confirm/index.spec.tsx new file mode 100644 index 0000000000..c2f67cc35e --- /dev/null +++ b/web/app/components/base/confirm/index.spec.tsx @@ -0,0 +1,117 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import Confirm from '.' + +vi.mock('react-dom', async () => { + const actual = await vi.importActual<typeof import('react-dom')>('react-dom') + + return { + ...actual, + createPortal: (children: React.ReactNode) => children, + } +}) + +const onCancel = vi.fn() +const onConfirm = vi.fn() + +describe('Confirm Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('renders confirm correctly', () => { + render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />) + expect(screen.getByText('test title')).toBeInTheDocument() + }) + + it('does not render on isShow false', () => { + const { container } = render(<Confirm isShow={false} title="test title" onCancel={onCancel} onConfirm={onConfirm} />) + expect(container.firstChild).toBeNull() + }) + + it('hides after delay when isShow changes to false', () => { + vi.useFakeTimers() + const { rerender } = render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />) + expect(screen.getByText('test title')).toBeInTheDocument() + + rerender(<Confirm isShow={false} title="test title" onCancel={onCancel} onConfirm={onConfirm} />) + act(() => { + vi.advanceTimersByTime(200) + }) + expect(screen.queryByText('test title')).not.toBeInTheDocument() + vi.useRealTimers() + }) + + it('renders content when provided', () => { + render(<Confirm isShow={true} title="title" content="some description" onCancel={onCancel} onConfirm={onConfirm} />) + expect(screen.getByText('some description')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('showCancel prop works', () => { + render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} showCancel={false} />) + expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument() + }) + + it('showConfirm prop works', () => { + render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} showConfirm={false} />) + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.confirm' })).not.toBeInTheDocument() + }) + + it('renders custom confirm and cancel text', () => { + render(<Confirm isShow={true} title="title" confirmText="Yes" cancelText="No" onCancel={onCancel} onConfirm={onConfirm} />) + expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument() + }) + + it('disables confirm button when isDisabled is true', () => { + render(<Confirm isShow={true} title="title" isDisabled={true} onCancel={onCancel} onConfirm={onConfirm} />) + expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeDisabled() + }) + }) + + describe('User Interactions', () => { + it('clickAway is handled properly', () => { + render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />) + const overlay = screen.getByTestId('confirm-overlay') as HTMLElement + expect(overlay).toBeTruthy() + fireEvent.mouseDown(overlay) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('overlay click stops propagation', () => { + render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />) + const overlay = screen.getByTestId('confirm-overlay') as HTMLElement + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault') + const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation') + overlay.dispatchEvent(clickEvent) + expect(preventDefaultSpy).toHaveBeenCalled() + expect(stopPropagationSpy).toHaveBeenCalled() + }) + + it('does not close on click away when maskClosable is false', () => { + render(<Confirm isShow={true} title="test title" maskClosable={false} onCancel={onCancel} onConfirm={onConfirm} />) + const overlay = screen.getByTestId('confirm-overlay') as HTMLElement + fireEvent.mouseDown(overlay) + expect(onCancel).not.toHaveBeenCalled() + }) + + it('escape keyboard event works', () => { + render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('Enter keyboard event works', () => { + render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />) + fireEvent.keyDown(document, { key: 'Enter' }) + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onCancel).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/base/confirm/index.tsx b/web/app/components/base/confirm/index.tsx index 6ac1c93a80..caca67f977 100644 --- a/web/app/components/base/confirm/index.tsx +++ b/web/app/components/base/confirm/index.tsx @@ -101,6 +101,7 @@ function Confirm({ e.preventDefault() e.stopPropagation() }} + data-testid="confirm-overlay" > <div ref={dialogRef} className="relative w-full max-w-[480px] overflow-hidden"> <div className="shadows-shadow-lg flex max-w-full flex-col items-start rounded-2xl border-[0.5px] border-solid border-components-panel-border bg-components-panel-bg"> diff --git a/web/app/components/base/copy-feedback/index.spec.tsx b/web/app/components/base/copy-feedback/index.spec.tsx new file mode 100644 index 0000000000..f89331c1bb --- /dev/null +++ b/web/app/components/base/copy-feedback/index.spec.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import CopyFeedback, { CopyFeedbackNew } from '.' + +const mockCopy = vi.fn() +const mockReset = vi.fn() +let mockCopied = false + +vi.mock('foxact/use-clipboard', () => ({ + useClipboard: () => ({ + copy: mockCopy, + reset: mockReset, + copied: mockCopied, + }), +})) + +describe('CopyFeedback', () => { + beforeEach(() => { + mockCopied = false + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('renders the action button with copy icon', () => { + render(<CopyFeedback content="test content" />) + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('renders the copied icon when copied is true', () => { + mockCopied = true + render(<CopyFeedback content="test content" />) + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('calls copy with content when clicked', () => { + render(<CopyFeedback content="test content" />) + const button = screen.getByRole('button') + fireEvent.click(button.firstChild as Element) + expect(mockCopy).toHaveBeenCalledWith('test content') + }) + + it('calls reset on mouse leave', () => { + render(<CopyFeedback content="test content" />) + const button = screen.getByRole('button') + fireEvent.mouseLeave(button.firstChild as Element) + expect(mockReset).toHaveBeenCalledTimes(1) + }) + }) +}) + +describe('CopyFeedbackNew', () => { + beforeEach(() => { + mockCopied = false + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('renders the component', () => { + const { container } = render(<CopyFeedbackNew content="test content" />) + expect(container.querySelector('.cursor-pointer')).toBeInTheDocument() + }) + + it('applies copied CSS class when copied is true', () => { + mockCopied = true + const { container } = render(<CopyFeedbackNew content="test content" />) + const feedbackIcon = container.firstChild?.firstChild as Element + expect(feedbackIcon).toHaveClass(/_copied_.*/) + }) + + it('does not apply copied CSS class when not copied', () => { + const { container } = render(<CopyFeedbackNew content="test content" />) + const feedbackIcon = container.firstChild?.firstChild as Element + expect(feedbackIcon).not.toHaveClass(/_copied_.*/) + }) + }) + + describe('User Interactions', () => { + it('calls copy with content when clicked', () => { + const { container } = render(<CopyFeedbackNew content="test content" />) + const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement + fireEvent.click(clickableArea) + expect(mockCopy).toHaveBeenCalledWith('test content') + }) + + it('calls reset on mouse leave', () => { + const { container } = render(<CopyFeedbackNew content="test content" />) + const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement + fireEvent.mouseLeave(clickableArea) + expect(mockReset).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/base/emoji-picker/Inner.spec.tsx b/web/app/components/base/emoji-picker/Inner.spec.tsx new file mode 100644 index 0000000000..cd993af9e8 --- /dev/null +++ b/web/app/components/base/emoji-picker/Inner.spec.tsx @@ -0,0 +1,169 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import EmojiPickerInner from './Inner' + +vi.mock('@emoji-mart/data', () => ({ + default: { + categories: [ + { + id: 'nature', + emojis: ['rabbit', 'bear'], + }, + { + id: 'food', + emojis: ['apple', 'orange'], + }, + ], + }, +})) + +vi.mock('emoji-mart', () => ({ + init: vi.fn(), +})) + +vi.mock('@/utils/emoji', () => ({ + searchEmoji: vi.fn().mockResolvedValue(['dog', 'cat']), +})) + +describe('EmojiPickerInner', () => { + const mockOnSelect = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + // Define the custom element to avoid "Unknown custom element" warnings + if (!customElements.get('em-emoji')) { + customElements.define('em-emoji', class extends HTMLElement { + static get observedAttributes() { return ['id'] } + }) + } + }) + + describe('Rendering', () => { + it('renders initial categories and emojis correctly', () => { + render(<EmojiPickerInner onSelect={mockOnSelect} />) + + expect(screen.getByText('nature')).toBeInTheDocument() + expect(screen.getByText('food')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('calls searchEmoji and displays results when typing in search input', async () => { + render(<EmojiPickerInner onSelect={mockOnSelect} />) + const searchInput = screen.getByPlaceholderText('Search emojis...') + + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'anim' } }) + }) + + await waitFor(() => { + expect(screen.getByText('Search')).toBeInTheDocument() + }) + + const searchSection = screen.getByText('Search').parentElement + expect(searchSection?.querySelectorAll('em-emoji').length).toBe(2) + }) + + it('updates selected emoji and calls onSelect when an emoji is clicked', async () => { + render(<EmojiPickerInner onSelect={mockOnSelect} />) + const emojiContainers = screen.getAllByTestId(/^emoji-container-/) + + await act(async () => { + fireEvent.click(emojiContainers[0]) + }) + + expect(mockOnSelect).toHaveBeenCalledWith('rabbit', expect.any(String)) + }) + + it('toggles style colors display when clicking the chevron', async () => { + render(<EmojiPickerInner onSelect={mockOnSelect} />) + + expect(screen.queryByText('#FFEAD5')).not.toBeInTheDocument() + + const toggleButton = screen.getByTestId('toggle-colors') + expect(toggleButton).toBeInTheDocument() + + await act(async () => { + fireEvent.click(toggleButton!) + }) + + expect(screen.getByText('Choose Style')).toBeInTheDocument() + const colorOptions = document.querySelectorAll('[style^="background:"]') + expect(colorOptions.length).toBeGreaterThan(0) + }) + + it('updates background color and calls onSelect when a color is clicked', async () => { + render(<EmojiPickerInner onSelect={mockOnSelect} />) + + const toggleButton = screen.getByTestId('toggle-colors') + await act(async () => { + fireEvent.click(toggleButton!) + }) + + const emojiContainers = screen.getAllByTestId(/^emoji-container-/) + await act(async () => { + fireEvent.click(emojiContainers[0]) + }) + + mockOnSelect.mockClear() + + const colorOptions = document.querySelectorAll('[style^="background:"]') + await act(async () => { + fireEvent.click(colorOptions[1].parentElement!) + }) + + expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC') + }) + + it('updates selected emoji when clicking a search result', async () => { + render(<EmojiPickerInner onSelect={mockOnSelect} />) + const searchInput = screen.getByPlaceholderText('Search emojis...') + + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'anim' } }) + }) + + await screen.findByText('Search') + + const searchEmojis = screen.getAllByTestId(/^emoji-search-result-/) + await act(async () => { + fireEvent.click(searchEmojis![0]) + }) + + expect(mockOnSelect).toHaveBeenCalledWith('dog', expect.any(String)) + }) + + it('toggles style colors display back and forth', async () => { + render(<EmojiPickerInner onSelect={mockOnSelect} />) + + const toggleButton = screen.getByTestId('toggle-colors') + + await act(async () => { + fireEvent.click(toggleButton!) + }) + expect(screen.getByText('Choose Style')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByTestId('toggle-colors')!) // It should be the other icon now + }) + expect(screen.queryByText('#FFEAD5')).not.toBeInTheDocument() + }) + + it('clears search results when input is cleared', async () => { + render(<EmojiPickerInner onSelect={mockOnSelect} />) + const searchInput = screen.getByPlaceholderText('Search emojis...') + + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'anim' } }) + }) + + await screen.findByText('Search') + + await act(async () => { + fireEvent.change(searchInput, { target: { value: '' } }) + }) + + expect(screen.queryByText('Search')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/emoji-picker/Inner.tsx b/web/app/components/base/emoji-picker/Inner.tsx index f125cfa63b..4f249cd2e8 100644 --- a/web/app/components/base/emoji-picker/Inner.tsx +++ b/web/app/components/base/emoji-picker/Inner.tsx @@ -3,8 +3,6 @@ import type { EmojiMartData } from '@emoji-mart/data' import type { ChangeEvent, FC } from 'react' import data from '@emoji-mart/data' import { - ChevronDownIcon, - ChevronUpIcon, MagnifyingGlassIcon, } from '@heroicons/react/24/outline' import { init } from 'emoji-mart' @@ -97,7 +95,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ {isSearching && ( <> <div key="category-search" className="flex flex-col"> - <p className="system-xs-medium-uppercase mb-1 text-text-primary">Search</p> + <p className="mb-1 text-text-primary system-xs-medium-uppercase">Search</p> <div className="grid h-full w-full grid-cols-8 gap-1"> {searchedEmojis.map((emoji: string, index: number) => { return ( @@ -108,7 +106,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ setSelectedEmoji(emoji) }} > - <div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1"> + <div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1" data-testid={`emoji-search-result-${emoji}`}> <em-emoji id={emoji} /> </div> </div> @@ -122,7 +120,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ {categories.map((category, index: number) => { return ( <div key={`category-${index}`} className="flex flex-col"> - <p className="system-xs-medium-uppercase mb-1 text-text-primary">{category.id}</p> + <p className="mb-1 text-text-primary system-xs-medium-uppercase">{category.id}</p> <div className="grid h-full w-full grid-cols-8 gap-1"> {category.emojis.map((emoji, index: number) => { return ( @@ -133,7 +131,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ setSelectedEmoji(emoji) }} > - <div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1"> + <div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1" data-testid={`emoji-container-${emoji}`}> <em-emoji id={emoji} /> </div> </div> @@ -148,10 +146,10 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ {/* Color Select */} <div className={cn('flex items-center justify-between p-3 pb-0')}> - <p className="system-xs-medium-uppercase mb-2 text-text-primary">Choose Style</p> + <p className="mb-2 text-text-primary system-xs-medium-uppercase">Choose Style</p> {showStyleColors - ? <ChevronDownIcon className="h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} /> - : <ChevronUpIcon className="h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} />} + ? <span className="i-heroicons-chevron-down h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} data-testid="toggle-colors" /> + : <span className="i-heroicons-chevron-up h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} data-testid="toggle-colors" />} </div> {showStyleColors && ( <div className="grid w-full grid-cols-8 gap-1 px-3"> diff --git a/web/app/components/base/emoji-picker/index.spec.tsx b/web/app/components/base/emoji-picker/index.spec.tsx new file mode 100644 index 0000000000..f554549cee --- /dev/null +++ b/web/app/components/base/emoji-picker/index.spec.tsx @@ -0,0 +1,115 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import EmojiPicker from './index' + +vi.mock('@emoji-mart/data', () => ({ + default: { + categories: [ + { + id: 'category1', + name: 'Category 1', + emojis: ['emoji1', 'emoji2'], + }, + ], + }, +})) + +vi.mock('emoji-mart', () => ({ + init: vi.fn(), + SearchIndex: { + search: vi.fn().mockResolvedValue([{ skins: [{ native: '🔍' }] }]), + }, +})) + +vi.mock('@/utils/emoji', () => ({ + searchEmoji: vi.fn().mockResolvedValue(['🔍']), +})) + +describe('EmojiPicker', () => { + const mockOnSelect = vi.fn() + const mockOnClose = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('renders nothing when isModal is false', () => { + const { container } = render( + <EmojiPicker isModal={false} />, + ) + expect(container.firstChild).toBeNull() + }) + + it('renders modal when isModal is true', async () => { + await act(async () => { + render( + <EmojiPicker isModal={true} />, + ) + }) + expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument() + expect(screen.getByText(/Cancel/i)).toBeInTheDocument() + expect(screen.getByText(/OK/i)).toBeInTheDocument() + }) + + it('OK button is disabled initially', async () => { + await act(async () => { + render( + <EmojiPicker />, + ) + }) + const okButton = screen.getByText(/OK/i).closest('button') + expect(okButton).toBeDisabled() + }) + + it('applies custom className to modal wrapper', async () => { + const customClass = 'custom-wrapper-class' + await act(async () => { + render( + <EmojiPicker className={customClass} />, + ) + }) + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveClass(customClass) + }) + }) + + describe('User Interactions', () => { + it('calls onSelect with selected emoji and background when OK is clicked', async () => { + await act(async () => { + render( + <EmojiPicker onSelect={mockOnSelect} />, + ) + }) + + const emojiWrappers = screen.getAllByTestId(/^emoji-container-/) + expect(emojiWrappers.length).toBeGreaterThan(0) + await act(async () => { + fireEvent.click(emojiWrappers[0]) + }) + + const okButton = screen.getByText(/OK/i) + expect(okButton.closest('button')).not.toBeDisabled() + + await act(async () => { + fireEvent.click(okButton) + }) + + expect(mockOnSelect).toHaveBeenCalledWith(expect.any(String), expect.any(String)) + }) + + it('calls onClose when Cancel is clicked', async () => { + await act(async () => { + render( + <EmojiPicker onClose={mockOnClose} />, + ) + }) + + const cancelButton = screen.getByText(/Cancel/i) + await act(async () => { + fireEvent.click(cancelButton) + }) + + expect(mockOnClose).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/base/file-thumb/image-render.spec.tsx b/web/app/components/base/file-thumb/image-render.spec.tsx new file mode 100644 index 0000000000..cef41b912c --- /dev/null +++ b/web/app/components/base/file-thumb/image-render.spec.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import ImageRender from './image-render' + +describe('ImageRender Component', () => { + const mockProps = { + sourceUrl: 'https://example.com/image.jpg', + name: 'test-image.jpg', + } + + describe('Render', () => { + it('renders image with correct src and alt', () => { + render(<ImageRender {...mockProps} />) + + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', mockProps.sourceUrl) + expect(img).toHaveAttribute('alt', mockProps.name) + }) + }) +}) diff --git a/web/app/components/base/file-thumb/index.spec.tsx b/web/app/components/base/file-thumb/index.spec.tsx new file mode 100644 index 0000000000..205e6f8d6f --- /dev/null +++ b/web/app/components/base/file-thumb/index.spec.tsx @@ -0,0 +1,74 @@ +/* eslint-disable next/no-img-element */ +import type { ImgHTMLAttributes } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import FileThumb from './index' + +vi.mock('next/image', () => ({ + __esModule: true, + default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />, +})) + +describe('FileThumb Component', () => { + const mockImageFile = { + name: 'test-image.jpg', + mimeType: 'image/jpeg', + extension: '.jpg', + size: 1024, + sourceUrl: 'https://example.com/test-image.jpg', + } + + const mockNonImageFile = { + name: 'test.pdf', + mimeType: 'application/pdf', + extension: '.pdf', + size: 2048, + sourceUrl: 'https://example.com/test.pdf', + } + + describe('Render', () => { + it('renders image thumbnail correctly', () => { + render(<FileThumb file={mockImageFile} />) + + const img = screen.getByAltText(mockImageFile.name) + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', mockImageFile.sourceUrl) + }) + + it('renders file type icon for non-image files', () => { + const { container } = render(<FileThumb file={mockNonImageFile} />) + + expect(screen.queryByAltText(mockNonImageFile.name)).not.toBeInTheDocument() + const svgIcon = container.querySelector('svg') + expect(svgIcon).toBeInTheDocument() + }) + + it('wraps content inside tooltip', async () => { + const user = userEvent.setup() + render(<FileThumb file={mockImageFile} />) + + const trigger = screen.getByAltText(mockImageFile.name) + expect(trigger).toBeInTheDocument() + + await user.hover(trigger) + + const tooltipContent = await screen.findByText(mockImageFile.name) + expect(tooltipContent).toBeInTheDocument() + }) + }) + + describe('Interaction', () => { + it('calls onClick with file when clicked', () => { + const onClick = vi.fn() + + render(<FileThumb file={mockImageFile} onClick={onClick} />) + + const clickable = screen.getByAltText(mockImageFile.name).closest('div') as HTMLElement + + fireEvent.click(clickable) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(onClick).toHaveBeenCalledWith(mockImageFile) + }) + }) +}) diff --git a/web/app/components/base/linked-apps-panel/index.spec.tsx b/web/app/components/base/linked-apps-panel/index.spec.tsx new file mode 100644 index 0000000000..fb7e2e7e2b --- /dev/null +++ b/web/app/components/base/linked-apps-panel/index.spec.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { vi } from 'vitest' +import { AppModeEnum } from '@/types/app' +import LinkedAppsPanel from './index' + +vi.mock('next/link', () => ({ + default: ({ children, href, className }: { children: React.ReactNode, href: string, className: string }) => ( + <a href={href} className={className} data-testid="link-item"> + {children} + </a> + ), +})) + +describe('LinkedAppsPanel Component', () => { + const mockRelatedApps = [ + { + id: 'app-1', + name: 'Chatbot App', + mode: AppModeEnum.CHAT, + icon_type: 'emoji' as const, + icon: 'đŸ€–', + icon_background: '#FFEAD5', + icon_url: '', + }, + { + id: 'app-2', + name: 'Workflow App', + mode: AppModeEnum.WORKFLOW, + icon_type: 'image' as const, + icon: 'file-id', + icon_background: '#E4FBCC', + icon_url: 'https://example.com/icon.png', + }, + { + id: 'app-3', + name: '', + mode: AppModeEnum.AGENT_CHAT, + icon_type: 'emoji' as const, + icon: 'đŸ•”ïž', + icon_background: '#D3F8DF', + icon_url: '', + }, + ] + + describe('Render', () => { + it('renders correctly with multiple apps', () => { + render(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={false} />) + + const items = screen.getAllByTestId('link-item') + expect(items).toHaveLength(3) + + expect(screen.getByText('Chatbot App')).toBeInTheDocument() + expect(screen.getByText('Workflow App')).toBeInTheDocument() + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('displays correct app mode labels', () => { + render(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={false} />) + + expect(screen.getByText('Chatbot')).toBeInTheDocument() + expect(screen.getByText('Workflow')).toBeInTheDocument() + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + + it('hides app name and centers content in mobile mode', () => { + render(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={true} />) + + expect(screen.queryByText('Chatbot App')).not.toBeInTheDocument() + expect(screen.queryByText('Workflow App')).not.toBeInTheDocument() + + const items = screen.getAllByTestId('link-item') + expect(items[0]).toHaveClass('justify-center') + }) + + it('handles empty relatedApps list gracefully', () => { + const { container } = render(<LinkedAppsPanel relatedApps={[]} isMobile={false} />) + const items = screen.queryAllByTestId('link-item') + expect(items).toHaveLength(0) + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Interaction', () => { + it('renders correct links for each app', () => { + render(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={false} />) + + const items = screen.getAllByTestId('link-item') + expect(items[0]).toHaveAttribute('href', '/app/app-1/overview') + expect(items[1]).toHaveAttribute('href', '/app/app-2/overview') + }) + }) +}) diff --git a/web/app/components/base/list-empty/horizontal-line.spec.tsx b/web/app/components/base/list-empty/horizontal-line.spec.tsx new file mode 100644 index 0000000000..934183f1d3 --- /dev/null +++ b/web/app/components/base/list-empty/horizontal-line.spec.tsx @@ -0,0 +1,33 @@ +import { render } from '@testing-library/react' +import * as React from 'react' +import HorizontalLine from './horizontal-line' + +describe('HorizontalLine', () => { + describe('Render', () => { + it('renders correctly', () => { + const { container } = render(<HorizontalLine />) + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + expect(svg).toHaveAttribute('width', '240') + expect(svg).toHaveAttribute('height', '2') + }) + + it('renders linear gradient definition', () => { + const { container } = render(<HorizontalLine />) + const defs = container.querySelector('defs') + const linearGradient = container.querySelector('linearGradient') + expect(defs).toBeInTheDocument() + expect(linearGradient).toBeInTheDocument() + expect(linearGradient).toHaveAttribute('id', 'paint0_linear_8619_59125') + }) + }) + + describe('Style', () => { + it('applies custom className', () => { + const testClass = 'custom-test-class' + const { container } = render(<HorizontalLine className={testClass} />) + const svg = container.querySelector('svg') + expect(svg).toHaveClass(testClass) + }) + }) +}) diff --git a/web/app/components/base/list-empty/index.spec.tsx b/web/app/components/base/list-empty/index.spec.tsx new file mode 100644 index 0000000000..aac1480a60 --- /dev/null +++ b/web/app/components/base/list-empty/index.spec.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import ListEmpty from './index' + +describe('ListEmpty Component', () => { + describe('Render', () => { + it('renders default icon when no icon is provided', () => { + const { container } = render(<ListEmpty />) + expect(container.querySelector('[data-icon="Variable02"]')).toBeInTheDocument() + }) + + it('renders custom icon when provided', () => { + const { container } = render(<ListEmpty icon={<div data-testid="custom-icon" />} />) + expect(container.querySelector('[data-icon="Variable02"]')).not.toBeInTheDocument() + expect(screen.getByTestId('custom-icon')).toBeInTheDocument() + }) + + it('renders design lines', () => { + const { container } = render(<ListEmpty />) + const svgs = container.querySelectorAll('svg') + expect(svgs).toHaveLength(5) + }) + }) + + describe('Props', () => { + it('renders title and description correctly', () => { + const testTitle = 'Empty List' + const testDescription = <span data-testid="desc">No items found</span> + + render(<ListEmpty title={testTitle} description={testDescription} />) + + expect(screen.getByText(testTitle)).toBeInTheDocument() + expect(screen.getByTestId('desc')).toBeInTheDocument() + expect(screen.getByText('No items found')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/list-empty/vertical-line.spec.tsx b/web/app/components/base/list-empty/vertical-line.spec.tsx new file mode 100644 index 0000000000..47e071d7fa --- /dev/null +++ b/web/app/components/base/list-empty/vertical-line.spec.tsx @@ -0,0 +1,33 @@ +import { render } from '@testing-library/react' +import * as React from 'react' +import VerticalLine from './vertical-line' + +describe('VerticalLine', () => { + describe('Render', () => { + it('renders correctly', () => { + const { container } = render(<VerticalLine />) + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + expect(svg).toHaveAttribute('width', '2') + expect(svg).toHaveAttribute('height', '132') + }) + + it('renders linear gradient definition', () => { + const { container } = render(<VerticalLine />) + const defs = container.querySelector('defs') + const linearGradient = container.querySelector('linearGradient') + expect(defs).toBeInTheDocument() + expect(linearGradient).toBeInTheDocument() + expect(linearGradient).toHaveAttribute('id', 'paint0_linear_8619_59128') + }) + }) + + describe('Style', () => { + it('applies custom className', () => { + const testClass = 'custom-test-class' + const { container } = render(<VerticalLine className={testClass} />) + const svg = container.querySelector('svg') + expect(svg).toHaveClass(testClass) + }) + }) +}) diff --git a/web/app/components/base/logo/dify-logo.spec.tsx b/web/app/components/base/logo/dify-logo.spec.tsx new file mode 100644 index 0000000000..834fb8f28e --- /dev/null +++ b/web/app/components/base/logo/dify-logo.spec.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react' +import useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' +import DifyLogo from './dify-logo' + +vi.mock('@/hooks/use-theme', () => ({ + default: vi.fn(), +})) + +vi.mock('@/utils/var', () => ({ + basePath: '/test-base-path', +})) + +describe('DifyLogo', () => { + const mockUseTheme = { + theme: Theme.light, + themes: ['light', 'dark'], + setTheme: vi.fn(), + resolvedTheme: Theme.light, + systemTheme: Theme.light, + forcedTheme: undefined, + } + + beforeEach(() => { + vi.mocked(useTheme).mockReturnValue(mockUseTheme as ReturnType<typeof useTheme>) + }) + + describe('Render', () => { + it('renders correctly with default props', () => { + render(<DifyLogo />) + const img = screen.getByRole('img', { name: /dify logo/i }) + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', '/test-base-path/logo/logo.svg') + }) + }) + + describe('Props', () => { + it('applies custom size correctly', () => { + const { rerender } = render(<DifyLogo size="large" />) + let img = screen.getByRole('img', { name: /dify logo/i }) + expect(img).toHaveClass('w-16') + expect(img).toHaveClass('h-7') + + rerender(<DifyLogo size="small" />) + img = screen.getByRole('img', { name: /dify logo/i }) + expect(img).toHaveClass('w-9') + expect(img).toHaveClass('h-4') + }) + + it('applies custom style correctly', () => { + render(<DifyLogo style="monochromeWhite" />) + const img = screen.getByRole('img', { name: /dify logo/i }) + expect(img).toHaveAttribute('src', '/test-base-path/logo/logo-monochrome-white.svg') + }) + + it('applies custom className', () => { + render(<DifyLogo className="custom-test-class" />) + const img = screen.getByRole('img', { name: /dify logo/i }) + expect(img).toHaveClass('custom-test-class') + }) + }) + + describe('Theme behavior', () => { + it('uses monochromeWhite logo in dark theme when style is default', () => { + vi.mocked(useTheme).mockReturnValue({ + ...mockUseTheme, + theme: Theme.dark, + } as ReturnType<typeof useTheme>) + render(<DifyLogo style="default" />) + const img = screen.getByRole('img', { name: /dify logo/i }) + expect(img).toHaveAttribute('src', '/test-base-path/logo/logo-monochrome-white.svg') + }) + + it('uses monochromeWhite logo in dark theme when style is monochromeWhite', () => { + vi.mocked(useTheme).mockReturnValue({ + ...mockUseTheme, + theme: Theme.dark, + } as ReturnType<typeof useTheme>) + render(<DifyLogo style="monochromeWhite" />) + const img = screen.getByRole('img', { name: /dify logo/i }) + expect(img).toHaveAttribute('src', '/test-base-path/logo/logo-monochrome-white.svg') + }) + + it('uses default logo in light theme when style is default', () => { + vi.mocked(useTheme).mockReturnValue({ + ...mockUseTheme, + theme: Theme.light, + } as ReturnType<typeof useTheme>) + render(<DifyLogo style="default" />) + const img = screen.getByRole('img', { name: /dify logo/i }) + expect(img).toHaveAttribute('src', '/test-base-path/logo/logo.svg') + }) + }) +}) diff --git a/web/app/components/base/logo/logo-embedded-chat-avatar.spec.tsx b/web/app/components/base/logo/logo-embedded-chat-avatar.spec.tsx new file mode 100644 index 0000000000..f3c374dbd9 --- /dev/null +++ b/web/app/components/base/logo/logo-embedded-chat-avatar.spec.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react' +import LogoEmbeddedChatAvatar from './logo-embedded-chat-avatar' + +vi.mock('@/utils/var', () => ({ + basePath: '/test-base-path', +})) + +describe('LogoEmbeddedChatAvatar', () => { + describe('Render', () => { + it('renders correctly with default props', () => { + render(<LogoEmbeddedChatAvatar />) + const img = screen.getByRole('img', { name: /logo/i }) + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', '/test-base-path/logo/logo-embedded-chat-avatar.png') + }) + }) + + describe('Props', () => { + it('applies custom className correctly', () => { + const customClass = 'custom-avatar-class' + render(<LogoEmbeddedChatAvatar className={customClass} />) + const img = screen.getByRole('img', { name: /logo/i }) + expect(img).toHaveClass(customClass) + }) + + it('has valid alt text', () => { + render(<LogoEmbeddedChatAvatar />) + const img = screen.getByRole('img', { name: /logo/i }) + expect(img).toHaveAttribute('alt', 'logo') + }) + }) +}) diff --git a/web/app/components/base/logo/logo-embedded-chat-header.spec.tsx b/web/app/components/base/logo/logo-embedded-chat-header.spec.tsx new file mode 100644 index 0000000000..74247036d3 --- /dev/null +++ b/web/app/components/base/logo/logo-embedded-chat-header.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import LogoEmbeddedChatHeader from './logo-embedded-chat-header' + +vi.mock('@/utils/var', () => ({ + basePath: '/test-base-path', +})) + +describe('LogoEmbeddedChatHeader', () => { + it('renders correctly with default props', () => { + const { container } = render(<LogoEmbeddedChatHeader />) + const img = screen.getByRole('img', { name: /logo/i }) + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', '/test-base-path/logo/logo-embedded-chat-header.png') + + const sources = container.querySelectorAll('source') + expect(sources).toHaveLength(3) + expect(sources[0]).toHaveAttribute('srcSet', '/logo/logo-embedded-chat-header.png') + expect(sources[1]).toHaveAttribute('srcSet', '/logo/logo-embedded-chat-header@2x.png') + expect(sources[2]).toHaveAttribute('srcSet', '/logo/logo-embedded-chat-header@3x.png') + }) + + it('applies custom className correctly', () => { + const customClass = 'custom-header-class' + render(<LogoEmbeddedChatHeader className={customClass} />) + const img = screen.getByRole('img', { name: /logo/i }) + expect(img).toHaveClass(customClass) + expect(img).toHaveClass('h-6') + }) +}) diff --git a/web/app/components/base/logo/logo-site.spec.tsx b/web/app/components/base/logo/logo-site.spec.tsx new file mode 100644 index 0000000000..956485305b --- /dev/null +++ b/web/app/components/base/logo/logo-site.spec.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import LogoSite from './logo-site' + +vi.mock('@/utils/var', () => ({ + basePath: '/test-base-path', +})) + +describe('LogoSite', () => { + it('renders correctly with default props', () => { + render(<LogoSite />) + const img = screen.getByRole('img', { name: /logo/i }) + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', '/test-base-path/logo/logo.png') + }) + + it('applies custom className correctly', () => { + const customClass = 'custom-site-class' + render(<LogoSite className={customClass} />) + const img = screen.getByRole('img', { name: /logo/i }) + expect(img).toHaveClass(customClass) + }) +}) diff --git a/web/app/components/base/search-input/index.spec.tsx b/web/app/components/base/search-input/index.spec.tsx new file mode 100644 index 0000000000..db70087d85 --- /dev/null +++ b/web/app/components/base/search-input/index.spec.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import SearchInput from '.' + +describe('SearchInput', () => { + describe('Render', () => { + it('renders correctly with default props', () => { + render(<SearchInput value="" onChange={() => {}} />) + const input = screen.getByPlaceholderText('common.operation.search') + expect(input).toBeInTheDocument() + expect(input).toHaveValue('') + }) + + it('renders custom placeholder', () => { + render(<SearchInput value="" onChange={() => {}} placeholder="Custom Placeholder" />) + expect(screen.getByPlaceholderText('Custom Placeholder')).toBeInTheDocument() + }) + + it('shows clear button when value is present', () => { + const onChange = vi.fn() + render(<SearchInput value="has value" onChange={onChange} />) + + const clearButton = screen.getByLabelText('common.operation.clear') + expect(clearButton).toBeInTheDocument() + }) + }) + + describe('Interaction', () => { + it('calls onChange when typing', () => { + const onChange = vi.fn() + render(<SearchInput value="" onChange={onChange} />) + const input = screen.getByPlaceholderText('common.operation.search') + + fireEvent.change(input, { target: { value: 'test' } }) + expect(onChange).toHaveBeenCalledWith('test') + }) + + it('handles composition events', () => { + const onChange = vi.fn() + render(<SearchInput value="initial" onChange={onChange} />) + const input = screen.getByPlaceholderText('common.operation.search') + + // Start composition + fireEvent.compositionStart(input) + fireEvent.change(input, { target: { value: 'final' } }) + + // While composing, onChange should NOT be called + expect(onChange).not.toHaveBeenCalled() + expect(input).toHaveValue('final') + + // End composition + fireEvent.compositionEnd(input) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith('final') + }) + + it('calls onChange with empty string when clear button is clicked', () => { + const onChange = vi.fn() + render(<SearchInput value="has value" onChange={onChange} />) + + const clearButton = screen.getByLabelText('common.operation.clear') + fireEvent.click(clearButton) + expect(onChange).toHaveBeenCalledWith('') + }) + + it('updates focus state on focus/blur', () => { + const { container } = render(<SearchInput value="" onChange={() => {}} />) + const wrapper = container.firstChild as HTMLElement + const input = screen.getByPlaceholderText('common.operation.search') + + fireEvent.focus(input) + expect(wrapper).toHaveClass(/bg-components-input-bg-active/) + + fireEvent.blur(input) + expect(wrapper).not.toHaveClass(/bg-components-input-bg-active/) + }) + }) + + describe('Style', () => { + it('applies white style', () => { + const { container } = render(<SearchInput value="" onChange={() => {}} white />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('!bg-white') + }) + + it('applies custom className', () => { + const { container } = render(<SearchInput value="" onChange={() => {}} className="custom-test" />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-test') + }) + }) +}) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index f55a49c564..5997abac8e 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1724,11 +1724,6 @@ "count": 10 } }, - "app/components/base/checkbox-list/index.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 6 - } - }, "app/components/base/checkbox/index.stories.tsx": { "no-console": { "count": 1 @@ -1858,9 +1853,6 @@ "app/components/base/emoji-picker/Inner.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 3 } }, "app/components/base/encrypted-bottom/index.tsx": {