From b55c0ec4de805e49218040f9b928379172a9d948 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:26:47 +0800 Subject: [PATCH 01/30] fix: revert "refactor: api/controllers/console/feature.py (test)" (#31850) --- api/controllers/console/feature.py | 94 +++--- .../console/test_fastopenapi_feature.py | 291 ------------------ 2 files changed, 48 insertions(+), 337 deletions(-) delete mode 100644 api/tests/unit_tests/controllers/console/test_fastopenapi_feature.py diff --git a/api/controllers/console/feature.py b/api/controllers/console/feature.py index 1e98d622fe..d3811e2d1b 100644 --- a/api/controllers/console/feature.py +++ b/api/controllers/console/feature.py @@ -1,58 +1,60 @@ -from pydantic import BaseModel, Field +from flask_restx import Resource, fields from werkzeug.exceptions import Unauthorized -from controllers.fastopenapi import console_router from libs.login import current_account_with_tenant, current_user, login_required -from services.feature_service import FeatureModel, FeatureService, SystemFeatureModel +from services.feature_service import FeatureService +from . import console_ns from .wraps import account_initialization_required, cloud_utm_record, setup_required -class FeatureResponse(BaseModel): - features: FeatureModel = Field(description="Feature configuration object") +@console_ns.route("/features") +class FeatureApi(Resource): + @console_ns.doc("get_tenant_features") + @console_ns.doc(description="Get feature configuration for current tenant") + @console_ns.response( + 200, + "Success", + console_ns.model("FeatureResponse", {"features": fields.Raw(description="Feature configuration object")}), + ) + @setup_required + @login_required + @account_initialization_required + @cloud_utm_record + def get(self): + """Get feature configuration for current tenant""" + _, current_tenant_id = current_account_with_tenant() + + return FeatureService.get_features(current_tenant_id).model_dump() -class SystemFeatureResponse(BaseModel): - features: SystemFeatureModel = Field(description="System feature configuration object") +@console_ns.route("/system-features") +class SystemFeatureApi(Resource): + @console_ns.doc("get_system_features") + @console_ns.doc(description="Get system-wide feature configuration") + @console_ns.response( + 200, + "Success", + console_ns.model( + "SystemFeatureResponse", {"features": fields.Raw(description="System feature configuration object")} + ), + ) + def get(self): + """Get system-wide feature configuration + NOTE: This endpoint is unauthenticated by design, as it provides system features + data required for dashboard initialization. -@console_router.get( - "/features", - response_model=FeatureResponse, - tags=["console"], -) -@setup_required -@login_required -@account_initialization_required -@cloud_utm_record -def get_tenant_features() -> FeatureResponse: - """Get feature configuration for current tenant.""" - _, current_tenant_id = current_account_with_tenant() + Authentication would create circular dependency (can't login without dashboard loading). - return FeatureResponse(features=FeatureService.get_features(current_tenant_id)) - - -@console_router.get( - "/system-features", - response_model=SystemFeatureResponse, - tags=["console"], -) -def get_system_features() -> SystemFeatureResponse: - """Get system-wide feature configuration - - NOTE: This endpoint is unauthenticated by design, as it provides system features - data required for dashboard initialization. - - Authentication would create circular dependency (can't login without dashboard loading). - - Only non-sensitive configuration data should be returned by this endpoint. - """ - # NOTE(QuantumGhost): ideally we should access `current_user.is_authenticated` - # without a try-catch. However, due to the implementation of user loader (the `load_user_from_request` - # in api/extensions/ext_login.py), accessing `current_user.is_authenticated` will - # raise `Unauthorized` exception if authentication token is not provided. - try: - is_authenticated = current_user.is_authenticated - except Unauthorized: - is_authenticated = False - return SystemFeatureResponse(features=FeatureService.get_system_features(is_authenticated=is_authenticated)) + Only non-sensitive configuration data should be returned by this endpoint. + """ + # NOTE(QuantumGhost): ideally we should access `current_user.is_authenticated` + # without a try-catch. However, due to the implementation of user loader (the `load_user_from_request` + # in api/extensions/ext_login.py), accessing `current_user.is_authenticated` will + # raise `Unauthorized` exception if authentication token is not provided. + try: + is_authenticated = current_user.is_authenticated + except Unauthorized: + is_authenticated = False + return FeatureService.get_system_features(is_authenticated=is_authenticated).model_dump() diff --git a/api/tests/unit_tests/controllers/console/test_fastopenapi_feature.py b/api/tests/unit_tests/controllers/console/test_fastopenapi_feature.py deleted file mode 100644 index 68495dd979..0000000000 --- a/api/tests/unit_tests/controllers/console/test_fastopenapi_feature.py +++ /dev/null @@ -1,291 +0,0 @@ -import builtins -import contextlib -import importlib -import sys -from unittest.mock import MagicMock, PropertyMock, patch - -import pytest -from flask import Flask -from flask.views import MethodView -from werkzeug.exceptions import Unauthorized - -from extensions import ext_fastopenapi -from extensions.ext_database import db -from services.feature_service import FeatureModel, SystemFeatureModel - - -@pytest.fixture -def app(): - """ - Creates a Flask application instance configured for testing. - """ - app = Flask(__name__) - app.config["TESTING"] = True - app.config["SECRET_KEY"] = "test-secret" - app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" - - # Initialize the database with the app - db.init_app(app) - - return app - - -@pytest.fixture(autouse=True) -def fix_method_view_issue(monkeypatch): - """ - Automatic fixture to patch 'builtins.MethodView'. - - Why this is needed: - The official legacy codebase contains a global patch in its initialization logic: - if not hasattr(builtins, "MethodView"): - builtins.MethodView = MethodView - - Some dependencies (like ext_fastopenapi or older Flask extensions) might implicitly - rely on 'MethodView' being available in the global builtins namespace. - - Refactoring Note: - While patching builtins is generally discouraged due to global side effects, - this fixture reproduces the production environment's state to ensure tests are realistic. - We use 'monkeypatch' to ensure that this change is undone after the test finishes, - keeping other tests isolated. - """ - if not hasattr(builtins, "MethodView"): - # 'raising=False' allows us to set an attribute that doesn't exist yet - monkeypatch.setattr(builtins, "MethodView", MethodView, raising=False) - - -# ------------------------------------------------------------------------------ -# Helper Functions for Fixture Complexity Reduction -# ------------------------------------------------------------------------------ - - -def _create_isolated_router(): - """ - Creates a fresh, isolated router instance to prevent route pollution. - """ - import controllers.fastopenapi - - # Dynamically get the class type (e.g., FlaskRouter) to avoid hardcoding dependencies - RouterClass = type(controllers.fastopenapi.console_router) - return RouterClass() - - -@contextlib.contextmanager -def _patch_auth_and_router(temp_router): - """ - Context manager that applies all necessary patches for: - 1. The console_router (redirecting to our isolated temp_router) - 2. Authentication decorators (disabling them with no-ops) - 3. User/Account loaders (mocking authenticated state) - """ - - def noop(f): - return f - - # We patch the SOURCE of the decorators/functions, not the destination module. - # This ensures that when 'controllers.console.feature' imports them, it gets the mocks. - with ( - patch("controllers.fastopenapi.console_router", temp_router), - patch("extensions.ext_fastopenapi.console_router", temp_router), - patch("controllers.console.wraps.setup_required", side_effect=noop), - patch("libs.login.login_required", side_effect=noop), - patch("controllers.console.wraps.account_initialization_required", side_effect=noop), - patch("controllers.console.wraps.cloud_utm_record", side_effect=noop), - patch("libs.login.current_account_with_tenant", return_value=(MagicMock(), "tenant-id")), - patch("libs.login.current_user", MagicMock(is_authenticated=True)), - ): - # Explicitly reload ext_fastopenapi to ensure it uses the patched console_router - import extensions.ext_fastopenapi - - importlib.reload(extensions.ext_fastopenapi) - - yield - - -def _force_reload_module(target_module: str, alias_module: str): - """ - Forces a reload of the specified module and handles sys.modules aliasing. - - Why reload? - Python decorators (like @route, @login_required) run at IMPORT time. - To apply our patches (mocks/no-ops) to these decorators, we must re-import - the module while the patches are active. - - Why alias? - If 'ext_fastopenapi' imports the controller as 'api.controllers...', but we import - it as 'controllers...', Python treats them as two separate modules. This causes: - 1. Double execution of decorators (registering routes twice -> AssertionError). - 2. Type mismatch errors (Class A from module X is not Class A from module Y). - - This function ensures both names point to the SAME loaded module instance. - """ - # 1. Clean existing entries to force re-import - if target_module in sys.modules: - del sys.modules[target_module] - if alias_module in sys.modules: - del sys.modules[alias_module] - - # 2. Import the module (triggering decorators with active patches) - module = importlib.import_module(target_module) - - # 3. Alias the module in sys.modules to prevent double loading - sys.modules[alias_module] = sys.modules[target_module] - - return module - - -def _cleanup_modules(target_module: str, alias_module: str): - """ - Removes the module and its alias from sys.modules to prevent side effects - on other tests. - """ - if target_module in sys.modules: - del sys.modules[target_module] - if alias_module in sys.modules: - del sys.modules[alias_module] - - -@pytest.fixture -def mock_feature_module_env(): - """ - Sets up a mocked environment for the feature module. - - This fixture orchestrates: - 1. Creating an isolated router. - 2. Patching authentication and global dependencies. - 3. Reloading the controller module to apply patches to decorators. - 4. cleaning up sys.modules afterwards. - """ - target_module = "controllers.console.feature" - alias_module = "api.controllers.console.feature" - - # 1. Prepare isolated router - temp_router = _create_isolated_router() - - # 2. Apply patches - try: - with _patch_auth_and_router(temp_router): - # 3. Reload module to register routes on the temp_router - feature_module = _force_reload_module(target_module, alias_module) - - yield feature_module - - finally: - # 4. Teardown: Clean up sys.modules - _cleanup_modules(target_module, alias_module) - - -# ------------------------------------------------------------------------------ -# Test Cases -# ------------------------------------------------------------------------------ - - -@pytest.mark.parametrize( - ("url", "service_mock_path", "mock_model_instance", "json_key"), - [ - ( - "/console/api/features", - "controllers.console.feature.FeatureService.get_features", - FeatureModel(can_replace_logo=True), - "features", - ), - ( - "/console/api/system-features", - "controllers.console.feature.FeatureService.get_system_features", - SystemFeatureModel(enable_marketplace=True), - "features", - ), - ], -) -def test_console_features_success(app, mock_feature_module_env, url, service_mock_path, mock_model_instance, json_key): - """ - Tests that the feature APIs return a 200 OK status and correct JSON structure. - """ - # Patch the service layer to return our mock model instance - with patch(service_mock_path, return_value=mock_model_instance): - # Initialize the API extension - ext_fastopenapi.init_app(app) - - client = app.test_client() - response = client.get(url) - - # Assertions - assert response.status_code == 200, f"Request failed with status {response.status_code}: {response.text}" - - # Verify the JSON response matches the Pydantic model dump - expected_data = mock_model_instance.model_dump(mode="json") - assert response.get_json() == {json_key: expected_data} - - -@pytest.mark.parametrize( - ("url", "service_mock_path"), - [ - ("/console/api/features", "controllers.console.feature.FeatureService.get_features"), - ("/console/api/system-features", "controllers.console.feature.FeatureService.get_system_features"), - ], -) -def test_console_features_service_error(app, mock_feature_module_env, url, service_mock_path): - """ - Tests how the application handles Service layer errors. - - Note: When an exception occurs in the view, it is typically caught by the framework - (Flask or the OpenAPI wrapper) and converted to a 500 error response. - This test verifies that the application returns a 500 status code. - """ - # Simulate a service failure - with patch(service_mock_path, side_effect=ValueError("Service Failure")): - ext_fastopenapi.init_app(app) - client = app.test_client() - - # When an exception occurs in the view, it is typically caught by the framework - # (Flask or the OpenAPI wrapper) and converted to a 500 error response. - response = client.get(url) - - assert response.status_code == 500 - # Check if the error details are exposed in the response (depends on error handler config) - # We accept either generic 500 or the specific error message - assert "Service Failure" in response.text or "Internal Server Error" in response.text - - -def test_system_features_unauthenticated(app, mock_feature_module_env): - """ - Tests that /console/api/system-features endpoint works without authentication. - - This test verifies the try-except block in get_system_features that handles - unauthenticated requests by passing is_authenticated=False to the service layer. - """ - feature_module = mock_feature_module_env - - # Override the behavior of the current_user mock - # The fixture patched 'libs.login.current_user', so 'controllers.console.feature.current_user' - # refers to that same Mock object. - mock_user = feature_module.current_user - - # Simulate property access raising Unauthorized - # Note: We must reset side_effect if it was set, or set it here. - # The fixture initialized it as MagicMock(is_authenticated=True). - # We want type(mock_user).is_authenticated to raise Unauthorized. - type(mock_user).is_authenticated = PropertyMock(side_effect=Unauthorized) - - # Patch the service layer for this specific test - with patch("controllers.console.feature.FeatureService.get_system_features") as mock_service: - # Setup mock service return value - mock_model = SystemFeatureModel(enable_marketplace=True) - mock_service.return_value = mock_model - - # Initialize app - ext_fastopenapi.init_app(app) - client = app.test_client() - - # Act - response = client.get("/console/api/system-features") - - # Assert - assert response.status_code == 200, f"Request failed: {response.text}" - - # Verify service was called with is_authenticated=False - mock_service.assert_called_once_with(is_authenticated=False) - - # Verify response body - expected_data = mock_model.model_dump(mode="json") - assert response.get_json() == {"features": expected_data} From aa7fe42615b7d0fd4a8fe638e8d57a5959a7f7a8 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 3 Feb 2026 13:47:30 +0800 Subject: [PATCH 02/30] test: enhance CommandSelector and GotoAnythingProvider tests (#31743) Co-authored-by: CodingOnStar --- .../app/create-app-modal/index.spec.tsx | 4 +- .../explore/create-app-modal/index.spec.tsx | 32 +- .../goto-anything/command-selector.spec.tsx | 201 ++++++ .../components/empty-state.spec.tsx | 157 +++++ .../goto-anything/components/empty-state.tsx | 105 ++++ .../goto-anything/components/footer.spec.tsx | 273 ++++++++ .../goto-anything/components/footer.tsx | 90 +++ .../goto-anything/components/index.ts | 14 + .../goto-anything/components/result-item.tsx | 38 ++ .../goto-anything/components/result-list.tsx | 49 ++ .../components/search-input.spec.tsx | 206 ++++++ .../goto-anything/components/search-input.tsx | 62 ++ .../components/goto-anything/context.spec.tsx | 77 ++- .../components/goto-anything/hooks/index.ts | 11 + .../hooks/use-goto-anything-modal.spec.ts | 291 +++++++++ .../hooks/use-goto-anything-modal.ts | 59 ++ .../use-goto-anything-navigation.spec.ts | 391 ++++++++++++ .../hooks/use-goto-anything-navigation.ts | 96 +++ .../hooks/use-goto-anything-results.spec.ts | 354 +++++++++++ .../hooks/use-goto-anything-results.ts | 115 ++++ .../hooks/use-goto-anything-search.spec.ts | 301 +++++++++ .../hooks/use-goto-anything-search.ts | 77 +++ .../components/goto-anything/index.spec.tsx | 581 +++++++++++++++-- web/app/components/goto-anything/index.tsx | 585 +++++------------- .../workflow-onboarding-modal/index.spec.tsx | 4 +- web/eslint-suppressions.json | 10 - 26 files changed, 3666 insertions(+), 517 deletions(-) create mode 100644 web/app/components/goto-anything/components/empty-state.spec.tsx create mode 100644 web/app/components/goto-anything/components/empty-state.tsx create mode 100644 web/app/components/goto-anything/components/footer.spec.tsx create mode 100644 web/app/components/goto-anything/components/footer.tsx create mode 100644 web/app/components/goto-anything/components/index.ts create mode 100644 web/app/components/goto-anything/components/result-item.tsx create mode 100644 web/app/components/goto-anything/components/result-list.tsx create mode 100644 web/app/components/goto-anything/components/search-input.spec.tsx create mode 100644 web/app/components/goto-anything/components/search-input.tsx create mode 100644 web/app/components/goto-anything/hooks/index.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-modal.spec.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-modal.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-navigation.spec.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-results.spec.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-results.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-search.spec.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-search.ts diff --git a/web/app/components/app/create-app-modal/index.spec.tsx b/web/app/components/app/create-app-modal/index.spec.tsx index cb8f4db67f..d26a581fda 100644 --- a/web/app/components/app/create-app-modal/index.spec.tsx +++ b/web/app/components/app/create-app-modal/index.spec.tsx @@ -124,7 +124,7 @@ describe('CreateAppModal', () => { const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder') fireEvent.change(nameInput, { target: { value: 'My App' } }) - fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' })) + fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ })) await waitFor(() => expect(mockCreateApp).toHaveBeenCalledWith({ name: 'My App', @@ -152,7 +152,7 @@ describe('CreateAppModal', () => { const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder') fireEvent.change(nameInput, { target: { value: 'My App' } }) - fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' })) + fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ })) await waitFor(() => expect(mockCreateApp).toHaveBeenCalled()) expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'boom' }) diff --git a/web/app/components/explore/create-app-modal/index.spec.tsx b/web/app/components/explore/create-app-modal/index.spec.tsx index 7ddb5a9082..65ec0e6096 100644 --- a/web/app/components/explore/create-app-modal/index.spec.tsx +++ b/web/app/components/explore/create-app-modal/index.spec.tsx @@ -138,7 +138,7 @@ describe('CreateAppModal', () => { setup({ appName: 'My App', isEditModal: false }) expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() }) @@ -146,7 +146,7 @@ describe('CreateAppModal', () => { setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 }) expect(screen.getByText('app.editAppTitle')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeInTheDocument() expect(screen.getByRole('switch')).toBeInTheDocument() expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5') }) @@ -166,7 +166,7 @@ describe('CreateAppModal', () => { it('should not render modal content when hidden', () => { setup({ show: false }) - expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument() }) }) @@ -175,13 +175,13 @@ describe('CreateAppModal', () => { it('should disable confirm action when confirmDisabled is true', () => { setup({ confirmDisabled: true }) - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled() + expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) it('should disable confirm action when appName is empty', () => { setup({ appName: ' ' }) - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled() + expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) }) @@ -245,7 +245,7 @@ describe('CreateAppModal', () => { setup({ isEditModal: false }) expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled() + expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) it('should allow saving when apps quota is reached in edit mode', () => { @@ -257,7 +257,7 @@ describe('CreateAppModal', () => { setup({ isEditModal: true }) expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeEnabled() + expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeEnabled() }) }) @@ -384,7 +384,7 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' })) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -433,7 +433,7 @@ describe('CreateAppModal', () => { expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() // Submit and verify the payload uses the original icon (cancel reverts to props) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -471,7 +471,7 @@ describe('CreateAppModal', () => { appIconBackground: '#000000', }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -495,7 +495,7 @@ describe('CreateAppModal', () => { const { onConfirm } = setup({ appDescription: 'Old description' }) fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -512,7 +512,7 @@ describe('CreateAppModal', () => { appIconBackground: null, }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -536,7 +536,7 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByRole('switch')) fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -551,7 +551,7 @@ describe('CreateAppModal', () => { it('should omit max_active_requests when input is empty', () => { const { onConfirm } = setup({ isEditModal: true, max_active_requests: null }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -564,7 +564,7 @@ describe('CreateAppModal', () => { const { onConfirm } = setup({ isEditModal: true, max_active_requests: null }) fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -576,7 +576,7 @@ describe('CreateAppModal', () => { it('should show toast error and not submit when name becomes empty before debounced submit runs', () => { const { onConfirm, onHide } = setup({ appName: 'My App' }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } }) act(() => { diff --git a/web/app/components/goto-anything/command-selector.spec.tsx b/web/app/components/goto-anything/command-selector.spec.tsx index 0ee2086058..0712a1afd6 100644 --- a/web/app/components/goto-anything/command-selector.spec.tsx +++ b/web/app/components/goto-anything/command-selector.spec.tsx @@ -81,4 +81,205 @@ describe('CommandSelector', () => { expect(onSelect).toHaveBeenCalledWith('/zen') }) + + it('should show all slash commands when no filter provided', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + // Should show the zen command from mock + expect(screen.getByText('/zen')).toBeInTheDocument() + }) + + it('should exclude slash action when in @ mode', () => { + const actions = { + ...createActions(), + slash: { + key: '/', + shortcut: '/', + title: 'Slash', + search: vi.fn(), + description: '', + } as ActionItem, + } + const onSelect = vi.fn() + + render( + + + , + ) + + // Should show @ commands but not / + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.queryByText('/')).not.toBeInTheDocument() + }) + + it('should show all actions when no filter in @ mode', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.getByText('@plugin')).toBeInTheDocument() + }) + + it('should set default command value when items exist but value does not', () => { + const actions = createActions() + const onSelect = vi.fn() + const onCommandValueChange = vi.fn() + + render( + + + , + ) + + expect(onCommandValueChange).toHaveBeenCalledWith('@app') + }) + + it('should NOT set command value when value already exists in items', () => { + const actions = createActions() + const onSelect = vi.fn() + const onCommandValueChange = vi.fn() + + render( + + + , + ) + + expect(onCommandValueChange).not.toHaveBeenCalled() + }) + + it('should show no matching commands message when filter has no results', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument() + }) + + it('should show no matching commands for slash mode with no results', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() + }) + + it('should render description for @ commands', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.actions.searchPluginsDesc')).toBeInTheDocument() + }) + + it('should render group header for @ mode', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('app.gotoAnything.selectSearchType')).toBeInTheDocument() + }) + + it('should render group header for slash mode', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument() + }) }) diff --git a/web/app/components/goto-anything/components/empty-state.spec.tsx b/web/app/components/goto-anything/components/empty-state.spec.tsx new file mode 100644 index 0000000000..e1e5e0dc89 --- /dev/null +++ b/web/app/components/goto-anything/components/empty-state.spec.tsx @@ -0,0 +1,157 @@ +import { render, screen } from '@testing-library/react' +import EmptyState from './empty-state' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string, shortcuts?: string }) => { + if (options?.shortcuts !== undefined) + return `${key}:${options.shortcuts}` + return `${options?.ns || 'common'}.${key}` + }, + }), +})) + +describe('EmptyState', () => { + describe('loading variant', () => { + it('should render loading spinner', () => { + render() + + expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument() + }) + + it('should have spinner animation class', () => { + const { container } = render() + + const spinner = container.querySelector('.animate-spin') + expect(spinner).toBeInTheDocument() + }) + }) + + describe('error variant', () => { + it('should render error message when error has message', () => { + const error = new Error('Connection failed') + render() + + expect(screen.getByText('app.gotoAnything.searchFailed')).toBeInTheDocument() + expect(screen.getByText('Connection failed')).toBeInTheDocument() + }) + + it('should render generic error when error has no message', () => { + render() + + expect(screen.getByText('app.gotoAnything.searchTemporarilyUnavailable')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.servicesUnavailableMessage')).toBeInTheDocument() + }) + + it('should render generic error when error is undefined', () => { + render() + + expect(screen.getByText('app.gotoAnything.searchTemporarilyUnavailable')).toBeInTheDocument() + }) + + it('should have red error text styling', () => { + const error = new Error('Test error') + const { container } = render() + + const errorText = container.querySelector('.text-red-500') + expect(errorText).toBeInTheDocument() + }) + }) + + describe('default variant', () => { + it('should render search title', () => { + render() + + expect(screen.getByText('app.gotoAnything.searchTitle')).toBeInTheDocument() + }) + + it('should render all hint messages', () => { + render() + + expect(screen.getByText('app.gotoAnything.searchHint')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.commandHint')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.slashHint')).toBeInTheDocument() + }) + }) + + describe('no-results variant', () => { + describe('general search mode', () => { + it('should render generic no results message', () => { + render() + + expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument() + }) + + it('should show specific search hint with shortcuts', () => { + const Actions = { + app: { key: '@app', shortcut: '@app' }, + plugin: { key: '@plugin', shortcut: '@plugin' }, + } as unknown as Record + render() + + expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:@app, @plugin')).toBeInTheDocument() + }) + }) + + describe('app search mode', () => { + it('should render no apps found message', () => { + render() + + expect(screen.getByText('app.gotoAnything.emptyState.noAppsFound')).toBeInTheDocument() + }) + + it('should show try different term hint', () => { + render() + + expect(screen.getByText('app.gotoAnything.emptyState.tryDifferentTerm')).toBeInTheDocument() + }) + }) + + describe('plugin search mode', () => { + it('should render no plugins found message', () => { + render() + + expect(screen.getByText('app.gotoAnything.emptyState.noPluginsFound')).toBeInTheDocument() + }) + }) + + describe('knowledge search mode', () => { + it('should render no knowledge bases found message', () => { + render() + + expect(screen.getByText('app.gotoAnything.emptyState.noKnowledgeBasesFound')).toBeInTheDocument() + }) + }) + + describe('node search mode', () => { + it('should render no workflow nodes found message', () => { + render() + + expect(screen.getByText('app.gotoAnything.emptyState.noWorkflowNodesFound')).toBeInTheDocument() + }) + }) + + describe('unknown search mode', () => { + it('should fallback to generic no results message', () => { + render() + + expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument() + }) + }) + }) + + describe('default props', () => { + it('should use general as default searchMode', () => { + render() + + expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument() + }) + + it('should use empty object as default Actions', () => { + render() + + // Should show empty shortcuts + expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/goto-anything/components/empty-state.tsx b/web/app/components/goto-anything/components/empty-state.tsx new file mode 100644 index 0000000000..a07bc1d45a --- /dev/null +++ b/web/app/components/goto-anything/components/empty-state.tsx @@ -0,0 +1,105 @@ +'use client' + +import type { FC } from 'react' +import type { ActionItem } from '../actions/types' +import { useTranslation } from 'react-i18next' + +export type EmptyStateVariant = 'no-results' | 'error' | 'default' | 'loading' + +export type EmptyStateProps = { + variant: EmptyStateVariant + searchMode?: string + error?: Error | null + Actions?: Record +} + +const EmptyState: FC = ({ + variant, + searchMode = 'general', + error, + Actions = {}, +}) => { + const { t } = useTranslation() + + if (variant === 'loading') { + return ( +
+
+
+ {t('gotoAnything.searching', { ns: 'app' })} +
+
+ ) + } + + if (variant === 'error') { + return ( +
+
+
+ {error?.message + ? t('gotoAnything.searchFailed', { ns: 'app' }) + : t('gotoAnything.searchTemporarilyUnavailable', { ns: 'app' })} +
+
+ {error?.message || t('gotoAnything.servicesUnavailableMessage', { ns: 'app' })} +
+
+
+ ) + } + + if (variant === 'default') { + return ( +
+
+
{t('gotoAnything.searchTitle', { ns: 'app' })}
+
+
{t('gotoAnything.searchHint', { ns: 'app' })}
+
{t('gotoAnything.commandHint', { ns: 'app' })}
+
{t('gotoAnything.slashHint', { ns: 'app' })}
+
+
+
+ ) + } + + // variant === 'no-results' + const isCommandSearch = searchMode !== 'general' + const commandType = isCommandSearch ? searchMode.replace('@', '') : '' + + const getNoResultsMessage = () => { + if (!isCommandSearch) { + return t('gotoAnything.noResults', { ns: 'app' }) + } + + const keyMap = { + app: 'gotoAnything.emptyState.noAppsFound', + plugin: 'gotoAnything.emptyState.noPluginsFound', + knowledge: 'gotoAnything.emptyState.noKnowledgeBasesFound', + node: 'gotoAnything.emptyState.noWorkflowNodesFound', + } as const + + return t(keyMap[commandType as keyof typeof keyMap] || 'gotoAnything.noResults', { ns: 'app' }) + } + + const getHintMessage = () => { + if (isCommandSearch) { + return t('gotoAnything.emptyState.tryDifferentTerm', { ns: 'app' }) + } + + const shortcuts = Object.values(Actions).map(action => action.shortcut).join(', ') + return t('gotoAnything.emptyState.trySpecificSearch', { ns: 'app', shortcuts }) + } + + return ( +
+
+
{getNoResultsMessage()}
+
{getHintMessage()}
+
+
+ ) +} + +export default EmptyState diff --git a/web/app/components/goto-anything/components/footer.spec.tsx b/web/app/components/goto-anything/components/footer.spec.tsx new file mode 100644 index 0000000000..3dfac5f71c --- /dev/null +++ b/web/app/components/goto-anything/components/footer.spec.tsx @@ -0,0 +1,273 @@ +import { render, screen } from '@testing-library/react' +import Footer from './footer' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string, count?: number, scope?: string }) => { + if (options?.count !== undefined) + return `${key}:${options.count}` + if (options?.scope) + return `${key}:${options.scope}` + return `${options?.ns || 'common'}.${key}` + }, + }), +})) + +describe('Footer', () => { + describe('left content', () => { + describe('when there are results', () => { + it('should show result count', () => { + render( +