diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml
index dbced47988..97027c2218 100644
--- a/.github/workflows/autofix.yml
+++ b/.github/workflows/autofix.yml
@@ -13,12 +13,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+
+ - name: Check Docker Compose inputs
+ id: docker-compose-changes
+ uses: tj-actions/changed-files@v46
+ with:
+ files: |
+ docker/generate_docker_compose
+ docker/.env.example
+ docker/docker-compose-template.yaml
+ docker/docker-compose.yaml
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: astral-sh/setup-uv@v6
+ - name: Generate Docker Compose
+ if: steps.docker-compose-changes.outputs.any_changed == 'true'
+ run: |
+ cd docker
+ ./generate_docker_compose
+
- run: |
cd api
uv sync --dev
diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml
index 2fb8121f74..8710f422fc 100644
--- a/.github/workflows/style.yml
+++ b/.github/workflows/style.yml
@@ -108,36 +108,6 @@ jobs:
working-directory: ./web
run: pnpm run type-check:tsgo
- docker-compose-template:
- name: Docker Compose Template
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
- with:
- persist-credentials: false
-
- - name: Check changed files
- id: changed-files
- uses: tj-actions/changed-files@v46
- with:
- files: |
- docker/generate_docker_compose
- docker/.env.example
- docker/docker-compose-template.yaml
- docker/docker-compose.yaml
-
- - name: Generate Docker Compose
- if: steps.changed-files.outputs.any_changed == 'true'
- run: |
- cd docker
- ./generate_docker_compose
-
- - name: Check for changes
- if: steps.changed-files.outputs.any_changed == 'true'
- run: git diff --exit-code
-
superlinter:
name: SuperLinter
runs-on: ubuntu-latest
diff --git a/api/controllers/common/file_response.py b/api/controllers/common/file_response.py
new file mode 100644
index 0000000000..ca8ea3d52e
--- /dev/null
+++ b/api/controllers/common/file_response.py
@@ -0,0 +1,57 @@
+import os
+from email.message import Message
+from urllib.parse import quote
+
+from flask import Response
+
+HTML_MIME_TYPES = frozenset({"text/html", "application/xhtml+xml"})
+HTML_EXTENSIONS = frozenset({"html", "htm"})
+
+
+def _normalize_mime_type(mime_type: str | None) -> str:
+ if not mime_type:
+ return ""
+ message = Message()
+ message["Content-Type"] = mime_type
+ return message.get_content_type().strip().lower()
+
+
+def _is_html_extension(extension: str | None) -> bool:
+ if not extension:
+ return False
+ return extension.lstrip(".").lower() in HTML_EXTENSIONS
+
+
+def is_html_content(mime_type: str | None, filename: str | None, extension: str | None = None) -> bool:
+ normalized_mime_type = _normalize_mime_type(mime_type)
+ if normalized_mime_type in HTML_MIME_TYPES:
+ return True
+
+ if _is_html_extension(extension):
+ return True
+
+ if filename:
+ return _is_html_extension(os.path.splitext(filename)[1])
+
+ return False
+
+
+def enforce_download_for_html(
+ response: Response,
+ *,
+ mime_type: str | None,
+ filename: str | None,
+ extension: str | None = None,
+) -> bool:
+ if not is_html_content(mime_type, filename, extension):
+ return False
+
+ if filename:
+ encoded_filename = quote(filename)
+ response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
+ else:
+ response.headers["Content-Disposition"] = "attachment"
+
+ response.headers["Content-Type"] = "application/octet-stream"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ return True
diff --git a/api/controllers/files/image_preview.py b/api/controllers/files/image_preview.py
index 64f47f426a..04db1c67cb 100644
--- a/api/controllers/files/image_preview.py
+++ b/api/controllers/files/image_preview.py
@@ -7,6 +7,7 @@ from werkzeug.exceptions import NotFound
import services
from controllers.common.errors import UnsupportedFileTypeError
+from controllers.common.file_response import enforce_download_for_html
from controllers.files import files_ns
from extensions.ext_database import db
from services.account_service import TenantService
@@ -138,6 +139,13 @@ class FilePreviewApi(Resource):
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
response.headers["Content-Type"] = "application/octet-stream"
+ enforce_download_for_html(
+ response,
+ mime_type=upload_file.mime_type,
+ filename=upload_file.name,
+ extension=upload_file.extension,
+ )
+
return response
diff --git a/api/controllers/files/tool_files.py b/api/controllers/files/tool_files.py
index c487a0a915..89aa472015 100644
--- a/api/controllers/files/tool_files.py
+++ b/api/controllers/files/tool_files.py
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from controllers.common.errors import UnsupportedFileTypeError
+from controllers.common.file_response import enforce_download_for_html
from controllers.files import files_ns
from core.tools.signature import verify_tool_file_signature
from core.tools.tool_file_manager import ToolFileManager
@@ -78,4 +79,11 @@ class ToolFileApi(Resource):
encoded_filename = quote(tool_file.name)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
+ enforce_download_for_html(
+ response,
+ mime_type=tool_file.mimetype,
+ filename=tool_file.name,
+ extension=extension,
+ )
+
return response
diff --git a/api/controllers/service_api/app/file_preview.py b/api/controllers/service_api/app/file_preview.py
index 60f422b88e..f853a124ef 100644
--- a/api/controllers/service_api/app/file_preview.py
+++ b/api/controllers/service_api/app/file_preview.py
@@ -5,6 +5,7 @@ from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field
+from controllers.common.file_response import enforce_download_for_html
from controllers.common.schema import register_schema_model
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
@@ -183,6 +184,13 @@ class FilePreviewApi(Resource):
# Override content-type for downloads to force download
response.headers["Content-Type"] = "application/octet-stream"
+ enforce_download_for_html(
+ response,
+ mime_type=upload_file.mime_type,
+ filename=upload_file.name,
+ extension=upload_file.extension,
+ )
+
# Add caching headers for performance
response.headers["Cache-Control"] = "public, max-age=3600" # Cache for 1 hour
diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py
index 353f3a646a..583a3584f7 100644
--- a/api/core/tools/entities/tool_entities.py
+++ b/api/core/tools/entities/tool_entities.py
@@ -153,11 +153,11 @@ class ToolInvokeMessage(BaseModel):
@classmethod
def transform_variable_value(cls, values):
"""
- Only basic types and lists are allowed.
+ Only basic types, lists, and None are allowed.
"""
value = values.get("variable_value")
- if not isinstance(value, dict | list | str | int | float | bool):
- raise ValueError("Only basic types and lists are allowed.")
+ if value is not None and not isinstance(value, dict | list | str | int | float | bool):
+ raise ValueError("Only basic types, lists, and None are allowed.")
# if stream is true, the value must be a string
if values.get("stream"):
diff --git a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py
index 93db417b15..08e0542d61 100644
--- a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py
+++ b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py
@@ -281,7 +281,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
# handle invoke result
- text = invoke_result.message.content or ""
+ text = invoke_result.message.get_text_content()
if not isinstance(text, str):
raise InvalidTextContentTypeError(f"Invalid text content type: {type(text)}. Expected str.")
diff --git a/api/tests/unit_tests/controllers/common/test_file_response.py b/api/tests/unit_tests/controllers/common/test_file_response.py
new file mode 100644
index 0000000000..2487c362bd
--- /dev/null
+++ b/api/tests/unit_tests/controllers/common/test_file_response.py
@@ -0,0 +1,46 @@
+from flask import Response
+
+from controllers.common.file_response import enforce_download_for_html, is_html_content
+
+
+class TestFileResponseHelpers:
+ def test_is_html_content_detects_mime_type(self):
+ mime_type = "text/html; charset=UTF-8"
+
+ result = is_html_content(mime_type, filename="file.txt", extension="txt")
+
+ assert result is True
+
+ def test_is_html_content_detects_extension(self):
+ result = is_html_content("text/plain", filename="report.html", extension=None)
+
+ assert result is True
+
+ def test_enforce_download_for_html_sets_headers(self):
+ response = Response("payload", mimetype="text/html")
+
+ updated = enforce_download_for_html(
+ response,
+ mime_type="text/html",
+ filename="unsafe.html",
+ extension="html",
+ )
+
+ assert updated is True
+ assert "attachment" in response.headers["Content-Disposition"]
+ assert response.headers["Content-Type"] == "application/octet-stream"
+ assert response.headers["X-Content-Type-Options"] == "nosniff"
+
+ def test_enforce_download_for_html_no_change_for_non_html(self):
+ response = Response("payload", mimetype="text/plain")
+
+ updated = enforce_download_for_html(
+ response,
+ mime_type="text/plain",
+ filename="notes.txt",
+ extension="txt",
+ )
+
+ assert updated is False
+ assert "Content-Disposition" not in response.headers
+ assert "X-Content-Type-Options" not in response.headers
diff --git a/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py b/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py
index acff191c79..1bdcd0f1a3 100644
--- a/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py
+++ b/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py
@@ -41,6 +41,7 @@ class TestFilePreviewApi:
upload_file = Mock(spec=UploadFile)
upload_file.id = str(uuid.uuid4())
upload_file.name = "test_file.jpg"
+ upload_file.extension = "jpg"
upload_file.mime_type = "image/jpeg"
upload_file.size = 1024
upload_file.key = "storage/key/test_file.jpg"
@@ -210,6 +211,19 @@ class TestFilePreviewApi:
assert mock_upload_file.name in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
+ def test_build_file_response_html_forces_attachment(self, file_preview_api, mock_upload_file):
+ """Test HTML files are forced to download"""
+ mock_generator = Mock()
+ mock_upload_file.mime_type = "text/html"
+ mock_upload_file.name = "unsafe.html"
+ mock_upload_file.extension = "html"
+
+ response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
+
+ assert "attachment" in response.headers["Content-Disposition"]
+ assert response.headers["Content-Type"] == "application/octet-stream"
+ assert response.headers["X-Content-Type-Options"] == "nosniff"
+
def test_build_file_response_audio_video(self, file_preview_api, mock_upload_file):
"""Test file response building for audio/video files"""
mock_generator = Mock()
diff --git a/web/app/components/base/badge/index.spec.tsx b/web/app/components/base/badge/index.spec.tsx
new file mode 100644
index 0000000000..74162841cf
--- /dev/null
+++ b/web/app/components/base/badge/index.spec.tsx
@@ -0,0 +1,360 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import Badge, { BadgeState, BadgeVariants } from './index'
+
+describe('Badge', () => {
+ describe('Rendering', () => {
+ it('should render as a div element with badge class', () => {
+ render(Test Badge)
+
+ const badge = screen.getByText('Test Badge')
+ expect(badge).toHaveClass('badge')
+ expect(badge.tagName).toBe('DIV')
+ })
+
+ it.each([
+ { children: undefined, label: 'no children' },
+ { children: '', label: 'empty string' },
+ ])('should render correctly when provided $label', ({ children }) => {
+ const { container } = render({children})
+
+ expect(container.firstChild).toHaveClass('badge')
+ })
+
+ it('should render React Node children correctly', () => {
+ render(
+
+ 🔔
+ ,
+ )
+
+ expect(screen.getByTestId('badge-with-icon')).toBeInTheDocument()
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
+ })
+ })
+
+ describe('size prop', () => {
+ it.each([
+ { size: undefined, label: 'medium (default)' },
+ { size: 's', label: 'small' },
+ { size: 'm', label: 'medium' },
+ { size: 'l', label: 'large' },
+ ] as const)('should render with $label size', ({ size }) => {
+ render(Test)
+
+ const expectedSize = size || 'm'
+ expect(screen.getByText('Test')).toHaveClass('badge', `badge-${expectedSize}`)
+ })
+ })
+
+ describe('state prop', () => {
+ it.each([
+ { state: BadgeState.Warning, label: 'warning', expectedClass: 'badge-warning' },
+ { state: BadgeState.Accent, label: 'accent', expectedClass: 'badge-accent' },
+ ])('should render with $label state', ({ state, expectedClass }) => {
+ render(State Test)
+
+ expect(screen.getByText('State Test')).toHaveClass(expectedClass)
+ })
+
+ it.each([
+ { state: undefined, label: 'default (undefined)' },
+ { state: BadgeState.Default, label: 'default (explicit)' },
+ ])('should use default styles when state is $label', ({ state }) => {
+ render(State Test)
+
+ const badge = screen.getByText('State Test')
+ expect(badge).not.toHaveClass('badge-warning', 'badge-accent')
+ })
+ })
+
+ describe('iconOnly prop', () => {
+ it.each([
+ { size: 's', iconOnly: false, label: 'small with text' },
+ { size: 's', iconOnly: true, label: 'small icon-only' },
+ { size: 'm', iconOnly: false, label: 'medium with text' },
+ { size: 'm', iconOnly: true, label: 'medium icon-only' },
+ { size: 'l', iconOnly: false, label: 'large with text' },
+ { size: 'l', iconOnly: true, label: 'large icon-only' },
+ ] as const)('should render correctly for $label', ({ size, iconOnly }) => {
+ const { container } = render(🔔)
+ const badge = screen.getByText('🔔')
+
+ // Verify badge renders with correct size
+ expect(badge).toHaveClass('badge', `badge-${size}`)
+
+ // Verify the badge is in the DOM and contains the content
+ expect(badge).toBeInTheDocument()
+ expect(container.firstChild).toBe(badge)
+ })
+
+ it('should apply icon-only padding when iconOnly is true', () => {
+ render(🔔)
+
+ // When iconOnly is true, the badge should have uniform padding (all sides equal)
+ const badge = screen.getByText('🔔')
+ expect(badge).toHaveClass('p-1')
+ })
+
+ it('should apply asymmetric padding when iconOnly is false', () => {
+ render(Badge)
+
+ // When iconOnly is false, the badge should have different horizontal and vertical padding
+ const badge = screen.getByText('Badge')
+ expect(badge).toHaveClass('px-[5px]', 'py-[2px]')
+ })
+ })
+
+ describe('uppercase prop', () => {
+ it.each([
+ { uppercase: undefined, label: 'default (undefined)', expected: 'system-2xs-medium' },
+ { uppercase: false, label: 'explicitly false', expected: 'system-2xs-medium' },
+ { uppercase: true, label: 'true', expected: 'system-2xs-medium-uppercase' },
+ ])('should apply $expected class when uppercase is $label', ({ uppercase, expected }) => {
+ render(Text)
+
+ expect(screen.getByText('Text')).toHaveClass(expected)
+ })
+ })
+
+ describe('styleCss prop', () => {
+ it('should apply custom inline styles correctly', () => {
+ const customStyles = {
+ backgroundColor: 'rgb(0, 0, 255)',
+ color: 'rgb(255, 255, 255)',
+ padding: '10px',
+ }
+ render(Styled Badge)
+
+ expect(screen.getByText('Styled Badge')).toHaveStyle(customStyles)
+ })
+
+ it('should apply inline styles without overriding core classes', () => {
+ render(Custom)
+
+ const badge = screen.getByText('Custom')
+ expect(badge).toHaveStyle({ backgroundColor: 'rgb(255, 0, 0)', margin: '5px' })
+ expect(badge).toHaveClass('badge')
+ })
+ })
+
+ describe('className prop', () => {
+ it.each([
+ {
+ props: { className: 'custom-badge' },
+ expected: ['badge', 'custom-badge'],
+ label: 'single custom class',
+ },
+ {
+ props: { className: 'custom-class another-class', size: 'l' as const },
+ expected: ['badge', 'badge-l', 'custom-class', 'another-class'],
+ label: 'multiple classes with size variant',
+ },
+ ])('should merge $label with default classes', ({ props, expected }) => {
+ render(Test)
+
+ expect(screen.getByText('Test')).toHaveClass(...expected)
+ })
+ })
+
+ describe('HTML attributes passthrough', () => {
+ it.each([
+ { attr: 'data-testid', value: 'custom-badge-id', label: 'data attribute' },
+ { attr: 'id', value: 'unique-badge', label: 'id attribute' },
+ { attr: 'aria-label', value: 'Notification badge', label: 'aria-label' },
+ { attr: 'title', value: 'Hover tooltip', label: 'title attribute' },
+ { attr: 'role', value: 'status', label: 'ARIA role' },
+ ])('should pass through $label correctly', ({ attr, value }) => {
+ render(Test)
+
+ expect(screen.getByText('Test')).toHaveAttribute(attr, value)
+ })
+
+ it('should support multiple HTML attributes simultaneously', () => {
+ render(
+
+ Test
+ ,
+ )
+
+ const badge = screen.getByTestId('multi-attr-badge')
+ expect(badge).toHaveAttribute('id', 'badge-123')
+ expect(badge).toHaveAttribute('aria-label', 'Status indicator')
+ expect(badge).toHaveAttribute('title', 'Current status')
+ })
+ })
+
+ describe('Event handlers', () => {
+ it.each([
+ { handler: 'onClick', trigger: fireEvent.click, label: 'click' },
+ { handler: 'onMouseEnter', trigger: fireEvent.mouseEnter, label: 'mouse enter' },
+ { handler: 'onMouseLeave', trigger: fireEvent.mouseLeave, label: 'mouse leave' },
+ ])('should trigger $handler when $label occurs', ({ handler, trigger }) => {
+ const mockHandler = vi.fn()
+ render(Badge)
+
+ trigger(screen.getByText('Badge'))
+
+ expect(mockHandler).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle user interaction flow with multiple events', () => {
+ const handlers = {
+ onClick: vi.fn(),
+ onMouseEnter: vi.fn(),
+ onMouseLeave: vi.fn(),
+ }
+ render(Interactive)
+
+ const badge = screen.getByText('Interactive')
+ fireEvent.mouseEnter(badge)
+ fireEvent.click(badge)
+ fireEvent.mouseLeave(badge)
+
+ expect(handlers.onMouseEnter).toHaveBeenCalledTimes(1)
+ expect(handlers.onClick).toHaveBeenCalledTimes(1)
+ expect(handlers.onMouseLeave).toHaveBeenCalledTimes(1)
+ })
+
+ it('should pass event object to handler with correct properties', () => {
+ const handleClick = vi.fn()
+ render(Event Badge)
+
+ fireEvent.click(screen.getByText('Event Badge'))
+
+ expect(handleClick).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'click',
+ }))
+ })
+ })
+
+ describe('Combined props', () => {
+ it('should correctly apply all props when used together', () => {
+ render(
+
+ Full Featured
+ ,
+ )
+
+ const badge = screen.getByTestId('combined-badge')
+ expect(badge).toHaveClass('badge', 'badge-l', 'badge-warning', 'system-2xs-medium-uppercase', 'custom-badge')
+ expect(badge).toHaveStyle({ backgroundColor: 'rgb(0, 0, 255)' })
+ expect(badge).toHaveTextContent('Full Featured')
+ })
+
+ it.each([
+ {
+ props: { size: 'l' as const, state: BadgeState.Accent },
+ expected: ['badge', 'badge-l', 'badge-accent'],
+ label: 'size and state variants',
+ },
+ {
+ props: { iconOnly: true, uppercase: true },
+ expected: ['badge', 'system-2xs-medium-uppercase'],
+ label: 'iconOnly and uppercase',
+ },
+ ])('should combine $label correctly', ({ props, expected }) => {
+ render(Test)
+
+ expect(screen.getByText('Test')).toHaveClass(...expected)
+ })
+
+ it('should handle event handlers with combined props', () => {
+ const handleClick = vi.fn()
+ render(
+
+ Test
+ ,
+ )
+
+ const badge = screen.getByText('Test')
+ expect(badge).toHaveClass('badge', 'badge-s', 'badge-warning', 'interactive')
+
+ fireEvent.click(badge)
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Edge cases', () => {
+ it.each([
+ { children: 42, text: '42', label: 'numeric value' },
+ { children: 0, text: '0', label: 'zero' },
+ ])('should render $label correctly', ({ children, text }) => {
+ render({children})
+
+ expect(screen.getByText(text)).toBeInTheDocument()
+ })
+
+ it.each([
+ { children: null, label: 'null' },
+ { children: false, label: 'boolean false' },
+ ])('should handle $label children without errors', ({ children }) => {
+ const { container } = render({children})
+
+ expect(container.firstChild).toHaveClass('badge')
+ })
+
+ it('should render complex nested content correctly', () => {
+ render(
+
+ 🔔
+ 5
+ ,
+ )
+
+ expect(screen.getByTestId('icon')).toBeInTheDocument()
+ expect(screen.getByTestId('count')).toBeInTheDocument()
+ })
+ })
+
+ describe('Component metadata and exports', () => {
+ it('should have correct displayName for debugging', () => {
+ expect(Badge.displayName).toBe('Badge')
+ })
+
+ describe('BadgeState enum', () => {
+ it.each([
+ { key: 'Warning', value: 'warning' },
+ { key: 'Accent', value: 'accent' },
+ { key: 'Default', value: '' },
+ ])('should export $key state with value "$value"', ({ key, value }) => {
+ expect(BadgeState[key as keyof typeof BadgeState]).toBe(value)
+ })
+ })
+
+ describe('BadgeVariants utility', () => {
+ it('should be a function', () => {
+ expect(typeof BadgeVariants).toBe('function')
+ })
+
+ it('should generate base badge class with default medium size', () => {
+ const result = BadgeVariants({})
+
+ expect(result).toContain('badge')
+ expect(result).toContain('badge-m')
+ })
+
+ it.each([
+ { size: 's' },
+ { size: 'm' },
+ { size: 'l' },
+ ] as const)('should generate correct classes for size=$size', ({ size }) => {
+ const result = BadgeVariants({ size })
+
+ expect(result).toContain('badge')
+ expect(result).toContain(`badge-${size}`)
+ })
+ })
+ })
+})
diff --git a/web/app/components/base/chip/index.spec.tsx b/web/app/components/base/chip/index.spec.tsx
new file mode 100644
index 0000000000..c19cc77b39
--- /dev/null
+++ b/web/app/components/base/chip/index.spec.tsx
@@ -0,0 +1,394 @@
+import type { Item } from './index'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import * as React from 'react'
+import Chip from './index'
+
+afterEach(cleanup)
+
+// Test data factory
+const createTestItems = (): Item[] => [
+ { value: 'all', name: 'All Items' },
+ { value: 'active', name: 'Active' },
+ { value: 'archived', name: 'Archived' },
+]
+
+describe('Chip', () => {
+ // Shared test props
+ let items: Item[]
+ let onSelect: (item: Item) => void
+ let onClear: () => void
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ items = createTestItems()
+ onSelect = vi.fn()
+ onClear = vi.fn()
+ })
+
+ // Helper function to render Chip with default props
+ const renderChip = (props: Partial> = {}) => {
+ return render(
+ ,
+ )
+ }
+
+ // Helper function to get the trigger element
+ const getTrigger = (container: HTMLElement) => {
+ return container.querySelector('[data-state]')
+ }
+
+ // Helper function to open dropdown panel
+ const openPanel = (container: HTMLElement) => {
+ const trigger = getTrigger(container)
+ if (trigger)
+ fireEvent.click(trigger)
+ }
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ renderChip()
+
+ expect(screen.getByText('All Items')).toBeInTheDocument()
+ })
+
+ it('should display current selected item name', () => {
+ renderChip({ value: 'active' })
+
+ expect(screen.getByText('Active')).toBeInTheDocument()
+ })
+
+ it('should display empty content when value does not match any item', () => {
+ const { container } = renderChip({ value: 'nonexistent' })
+
+ // When value doesn't match, no text should be displayed in trigger
+ const trigger = getTrigger(container)
+ // Check that there's no item name text (only icons should be present)
+ expect(trigger?.textContent?.trim()).toBeFalsy()
+ })
+ })
+
+ describe('Props', () => {
+ it('should update displayed item name when value prop changes', () => {
+ const { rerender } = renderChip({ value: 'all' })
+ expect(screen.getByText('All Items')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+ expect(screen.getByText('Archived')).toBeInTheDocument()
+ })
+
+ it('should show left icon by default', () => {
+ const { container } = renderChip()
+
+ // The filter icon should be visible
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('should hide left icon when showLeftIcon is false', () => {
+ renderChip({ showLeftIcon: false })
+
+ // When showLeftIcon is false, there should be no filter icon before the text
+ const textElement = screen.getByText('All Items')
+ const parent = textElement.closest('div[data-state]')
+ const icons = parent?.querySelectorAll('svg')
+
+ // Should only have the arrow icon, not the filter icon
+ expect(icons?.length).toBe(1)
+ })
+
+ it('should render custom left icon', () => {
+ const CustomIcon = () => ★
+
+ renderChip({ leftIcon: })
+
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
+ })
+
+ it('should apply custom className to trigger', () => {
+ const customClass = 'custom-chip-class'
+
+ const { container } = renderChip({ className: customClass })
+
+ const chipElement = container.querySelector(`.${customClass}`)
+ expect(chipElement).toBeInTheDocument()
+ })
+
+ it('should apply custom panelClassName to dropdown panel', () => {
+ const customPanelClass = 'custom-panel-class'
+
+ const { container } = renderChip({ panelClassName: customPanelClass })
+ openPanel(container)
+
+ // Panel is rendered in a portal, so check document.body
+ const panel = document.body.querySelector(`.${customPanelClass}`)
+ expect(panel).toBeInTheDocument()
+ })
+ })
+
+ describe('State Management', () => {
+ it('should toggle dropdown panel on trigger click', () => {
+ const { container } = renderChip()
+
+ // Initially closed - check data-state attribute
+ const trigger = getTrigger(container)
+ expect(trigger).toHaveAttribute('data-state', 'closed')
+
+ // Open panel
+ openPanel(container)
+ expect(trigger).toHaveAttribute('data-state', 'open')
+ // Panel items should be visible
+ expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
+
+ // Close panel
+ if (trigger)
+ fireEvent.click(trigger)
+ expect(trigger).toHaveAttribute('data-state', 'closed')
+ })
+
+ it('should close panel after selecting an item', () => {
+ const { container } = renderChip()
+
+ openPanel(container)
+ const trigger = getTrigger(container)
+ expect(trigger).toHaveAttribute('data-state', 'open')
+
+ // Click on an item in the dropdown panel
+ const activeItems = screen.getAllByText('Active')
+ // The second one should be in the dropdown
+ fireEvent.click(activeItems[activeItems.length - 1])
+
+ expect(trigger).toHaveAttribute('data-state', 'closed')
+ })
+ })
+
+ describe('Event Handlers', () => {
+ it('should call onSelect with correct item when item is clicked', () => {
+ const { container } = renderChip()
+
+ openPanel(container)
+ // Get all "Active" texts and click the one in the dropdown (should be the last one)
+ const activeItems = screen.getAllByText('Active')
+ fireEvent.click(activeItems[activeItems.length - 1])
+
+ expect(onSelect).toHaveBeenCalledTimes(1)
+ expect(onSelect).toHaveBeenCalledWith(items[1])
+ })
+
+ it('should call onClear when clear button is clicked', () => {
+ const { container } = renderChip({ value: 'active' })
+
+ // Find the close icon (last SVG in the trigger) and click its parent
+ const trigger = getTrigger(container)
+ const svgs = trigger?.querySelectorAll('svg')
+ // The close icon should be the last SVG element
+ const closeIcon = svgs?.[svgs.length - 1]
+ const clearButton = closeIcon?.parentElement
+
+ expect(clearButton).toBeInTheDocument()
+ if (clearButton)
+ fireEvent.click(clearButton)
+
+ expect(onClear).toHaveBeenCalledTimes(1)
+ })
+
+ it('should stop event propagation when clear button is clicked', () => {
+ const { container } = renderChip({ value: 'active' })
+
+ const trigger = getTrigger(container)
+ expect(trigger).toHaveAttribute('data-state', 'closed')
+
+ // Find the close icon (last SVG) and click its parent
+ const svgs = trigger?.querySelectorAll('svg')
+ const closeIcon = svgs?.[svgs.length - 1]
+ const clearButton = closeIcon?.parentElement
+
+ if (clearButton)
+ fireEvent.click(clearButton)
+
+ // Panel should remain closed
+ expect(trigger).toHaveAttribute('data-state', 'closed')
+ expect(onClear).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle multiple rapid clicks on trigger', () => {
+ const { container } = renderChip()
+
+ const trigger = getTrigger(container)
+
+ // Click 1: open
+ if (trigger)
+ fireEvent.click(trigger)
+ expect(trigger).toHaveAttribute('data-state', 'open')
+
+ // Click 2: close
+ if (trigger)
+ fireEvent.click(trigger)
+ expect(trigger).toHaveAttribute('data-state', 'closed')
+
+ // Click 3: open again
+ if (trigger)
+ fireEvent.click(trigger)
+ expect(trigger).toHaveAttribute('data-state', 'open')
+ })
+ })
+
+ describe('Conditional Rendering', () => {
+ it('should show arrow down icon when no value is selected', () => {
+ const { container } = renderChip({ value: '' })
+
+ // Should have SVG icons (filter icon and arrow down icon)
+ const svgs = container.querySelectorAll('svg')
+ expect(svgs.length).toBeGreaterThan(0)
+ })
+
+ it('should show clear button when value is selected', () => {
+ const { container } = renderChip({ value: 'active' })
+
+ // When value is selected, there should be an icon (the close icon)
+ const svgs = container.querySelectorAll('svg')
+ expect(svgs.length).toBeGreaterThan(0)
+ })
+
+ it('should not show clear button when no value is selected', () => {
+ const { container } = renderChip({ value: '' })
+
+ const trigger = getTrigger(container)
+
+ // When value is empty, the trigger should only have 2 SVGs (filter icon + arrow)
+ // When value is selected, it would have 2 SVGs (filter icon + close icon)
+ const svgs = trigger?.querySelectorAll('svg')
+ // Arrow icon should be present, close icon should not
+ expect(svgs?.length).toBe(2)
+
+ // Verify onClear hasn't been called
+ expect(onClear).not.toHaveBeenCalled()
+ })
+
+ it('should show dropdown content only when panel is open', () => {
+ const { container } = renderChip()
+
+ const trigger = getTrigger(container)
+
+ // Closed by default
+ expect(trigger).toHaveAttribute('data-state', 'closed')
+
+ openPanel(container)
+ expect(trigger).toHaveAttribute('data-state', 'open')
+ // Items should be duplicated (once in trigger, once in panel)
+ expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
+ })
+
+ it('should show check icon on selected item in dropdown', () => {
+ const { container } = renderChip({ value: 'active' })
+
+ openPanel(container)
+
+ // Find the dropdown panel items
+ const allActiveTexts = screen.getAllByText('Active')
+ // The dropdown item should be the last one
+ const dropdownItem = allActiveTexts[allActiveTexts.length - 1]
+ const parentContainer = dropdownItem.parentElement
+
+ // The check icon should be a sibling within the parent
+ const checkIcon = parentContainer?.querySelector('svg')
+ expect(checkIcon).toBeInTheDocument()
+ })
+
+ it('should render all items in dropdown when open', () => {
+ const { container } = renderChip()
+
+ openPanel(container)
+
+ // Each item should appear at least twice (once in potential selected state, once in dropdown)
+ // Use getAllByText to handle multiple occurrences
+ expect(screen.getAllByText('All Items').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('Active').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('Archived').length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty items array', () => {
+ const { container } = renderChip({ items: [], value: '' })
+
+ // Trigger should still render
+ const trigger = container.querySelector('[data-state]')
+ expect(trigger).toBeInTheDocument()
+ })
+
+ it('should handle value not in items list', () => {
+ const { container } = renderChip({ value: 'nonexistent' })
+
+ const trigger = getTrigger(container)
+ expect(trigger).toBeInTheDocument()
+
+ // The trigger should not display any item name text
+ expect(trigger?.textContent?.trim()).toBeFalsy()
+ })
+
+ it('should allow selecting already selected item', () => {
+ const { container } = renderChip({ value: 'active' })
+
+ openPanel(container)
+
+ // Click on the already selected item in the dropdown
+ const activeItems = screen.getAllByText('Active')
+ fireEvent.click(activeItems[activeItems.length - 1])
+
+ expect(onSelect).toHaveBeenCalledTimes(1)
+ expect(onSelect).toHaveBeenCalledWith(items[1])
+ })
+
+ it('should handle numeric values', () => {
+ const numericItems: Item[] = [
+ { value: 1, name: 'First' },
+ { value: 2, name: 'Second' },
+ { value: 3, name: 'Third' },
+ ]
+
+ const { container } = renderChip({ value: 2, items: numericItems })
+
+ expect(screen.getByText('Second')).toBeInTheDocument()
+
+ // Open panel and select Third
+ openPanel(container)
+
+ const thirdItems = screen.getAllByText('Third')
+ fireEvent.click(thirdItems[thirdItems.length - 1])
+
+ expect(onSelect).toHaveBeenCalledWith(numericItems[2])
+ })
+
+ it('should handle items with additional properties', () => {
+ const itemsWithExtra: Item[] = [
+ { value: 'a', name: 'Item A', customProp: 'extra1' },
+ { value: 'b', name: 'Item B', customProp: 'extra2' },
+ ]
+
+ const { container } = renderChip({ value: 'a', items: itemsWithExtra })
+
+ expect(screen.getByText('Item A')).toBeInTheDocument()
+
+ // Open panel and select Item B
+ openPanel(container)
+
+ const itemBs = screen.getAllByText('Item B')
+ fireEvent.click(itemBs[itemBs.length - 1])
+
+ expect(onSelect).toHaveBeenCalledWith(itemsWithExtra[1])
+ })
+ })
+})
diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx
index 2b2b1ee543..b31c283550 100644
--- a/web/app/components/workflow/index.tsx
+++ b/web/app/components/workflow/index.tsx
@@ -195,9 +195,11 @@ export const Workflow: FC = memo(({
const { nodesReadOnly } = useNodesReadOnly()
const { eventEmitter } = useEventEmitterContextContext()
+ const store = useStoreApi()
eventEmitter?.useSubscription((v: any) => {
if (v.type === WORKFLOW_DATA_UPDATE) {
setNodes(v.payload.nodes)
+ store.getState().setNodes(v.payload.nodes)
setEdges(v.payload.edges)
if (v.payload.viewport)
@@ -359,7 +361,6 @@ export const Workflow: FC = memo(({
}
}, [schemaTypeDefinitions, fetchInspectVars, isLoadedVars, vars, customTools, buildInTools, workflowTools, mcpTools, dataSourceList])
- const store = useStoreApi()
if (process.env.NODE_ENV === 'development') {
store.getState().onError = (code, message) => {
if (code === '002')