Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>tags/1.7.2
| @@ -28,6 +28,12 @@ from services.feature_service import FeatureService | |||
| ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] | |||
| def _validate_description_length(description): | |||
| if description and len(description) > 400: | |||
| raise ValueError("Description cannot exceed 400 characters.") | |||
| return description | |||
| class AppListApi(Resource): | |||
| @setup_required | |||
| @login_required | |||
| @@ -94,7 +100,7 @@ class AppListApi(Resource): | |||
| """Create app""" | |||
| parser = reqparse.RequestParser() | |||
| parser.add_argument("name", type=str, required=True, location="json") | |||
| parser.add_argument("description", type=str, location="json") | |||
| parser.add_argument("description", type=_validate_description_length, location="json") | |||
| parser.add_argument("mode", type=str, choices=ALLOW_CREATE_APP_MODES, location="json") | |||
| parser.add_argument("icon_type", type=str, location="json") | |||
| parser.add_argument("icon", type=str, location="json") | |||
| @@ -146,7 +152,7 @@ class AppApi(Resource): | |||
| parser = reqparse.RequestParser() | |||
| parser.add_argument("name", type=str, required=True, nullable=False, location="json") | |||
| parser.add_argument("description", type=str, location="json") | |||
| parser.add_argument("description", type=_validate_description_length, location="json") | |||
| parser.add_argument("icon_type", type=str, location="json") | |||
| parser.add_argument("icon", type=str, location="json") | |||
| parser.add_argument("icon_background", type=str, location="json") | |||
| @@ -189,7 +195,7 @@ class AppCopyApi(Resource): | |||
| parser = reqparse.RequestParser() | |||
| parser.add_argument("name", type=str, location="json") | |||
| parser.add_argument("description", type=str, location="json") | |||
| parser.add_argument("description", type=_validate_description_length, location="json") | |||
| parser.add_argument("icon_type", type=str, location="json") | |||
| parser.add_argument("icon", type=str, location="json") | |||
| parser.add_argument("icon_background", type=str, location="json") | |||
| @@ -41,7 +41,7 @@ def _validate_name(name): | |||
| def _validate_description_length(description): | |||
| if len(description) > 400: | |||
| if description and len(description) > 400: | |||
| raise ValueError("Description cannot exceed 400 characters.") | |||
| return description | |||
| @@ -113,7 +113,7 @@ class DatasetListApi(Resource): | |||
| ) | |||
| parser.add_argument( | |||
| "description", | |||
| type=str, | |||
| type=_validate_description_length, | |||
| nullable=True, | |||
| required=False, | |||
| default="", | |||
| @@ -29,7 +29,7 @@ def _validate_name(name): | |||
| def _validate_description_length(description): | |||
| if len(description) > 400: | |||
| if description and len(description) > 400: | |||
| raise ValueError("Description cannot exceed 400 characters.") | |||
| return description | |||
| @@ -87,7 +87,7 @@ class DatasetListApi(DatasetApiResource): | |||
| ) | |||
| parser.add_argument( | |||
| "description", | |||
| type=str, | |||
| type=_validate_description_length, | |||
| nullable=True, | |||
| required=False, | |||
| default="", | |||
| @@ -0,0 +1,168 @@ | |||
| """ | |||
| Unit tests for App description validation functions. | |||
| This test module validates the 400-character limit enforcement | |||
| for App descriptions across all creation and editing endpoints. | |||
| """ | |||
| import os | |||
| import sys | |||
| import pytest | |||
| # Add the API root to Python path for imports | |||
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) | |||
| class TestAppDescriptionValidationUnit: | |||
| """Unit tests for description validation function""" | |||
| def test_validate_description_length_function(self): | |||
| """Test the _validate_description_length function directly""" | |||
| from controllers.console.app.app import _validate_description_length | |||
| # Test valid descriptions | |||
| assert _validate_description_length("") == "" | |||
| assert _validate_description_length("x" * 400) == "x" * 400 | |||
| assert _validate_description_length(None) is None | |||
| # Test invalid descriptions | |||
| with pytest.raises(ValueError) as exc_info: | |||
| _validate_description_length("x" * 401) | |||
| assert "Description cannot exceed 400 characters." in str(exc_info.value) | |||
| with pytest.raises(ValueError) as exc_info: | |||
| _validate_description_length("x" * 500) | |||
| assert "Description cannot exceed 400 characters." in str(exc_info.value) | |||
| with pytest.raises(ValueError) as exc_info: | |||
| _validate_description_length("x" * 1000) | |||
| assert "Description cannot exceed 400 characters." in str(exc_info.value) | |||
| def test_validation_consistency_with_dataset(self): | |||
| """Test that App and Dataset validation functions are consistent""" | |||
| from controllers.console.app.app import _validate_description_length as app_validate | |||
| from controllers.console.datasets.datasets import _validate_description_length as dataset_validate | |||
| from controllers.service_api.dataset.dataset import _validate_description_length as service_dataset_validate | |||
| # Test same valid inputs | |||
| valid_desc = "x" * 400 | |||
| assert app_validate(valid_desc) == dataset_validate(valid_desc) == service_dataset_validate(valid_desc) | |||
| assert app_validate("") == dataset_validate("") == service_dataset_validate("") | |||
| assert app_validate(None) == dataset_validate(None) == service_dataset_validate(None) | |||
| # Test same invalid inputs produce same error | |||
| invalid_desc = "x" * 401 | |||
| app_error = None | |||
| dataset_error = None | |||
| service_dataset_error = None | |||
| try: | |||
| app_validate(invalid_desc) | |||
| except ValueError as e: | |||
| app_error = str(e) | |||
| try: | |||
| dataset_validate(invalid_desc) | |||
| except ValueError as e: | |||
| dataset_error = str(e) | |||
| try: | |||
| service_dataset_validate(invalid_desc) | |||
| except ValueError as e: | |||
| service_dataset_error = str(e) | |||
| assert app_error == dataset_error == service_dataset_error | |||
| assert app_error == "Description cannot exceed 400 characters." | |||
| def test_boundary_values(self): | |||
| """Test boundary values for description validation""" | |||
| from controllers.console.app.app import _validate_description_length | |||
| # Test exact boundary | |||
| exactly_400 = "x" * 400 | |||
| assert _validate_description_length(exactly_400) == exactly_400 | |||
| # Test just over boundary | |||
| just_over_400 = "x" * 401 | |||
| with pytest.raises(ValueError): | |||
| _validate_description_length(just_over_400) | |||
| # Test just under boundary | |||
| just_under_400 = "x" * 399 | |||
| assert _validate_description_length(just_under_400) == just_under_400 | |||
| def test_edge_cases(self): | |||
| """Test edge cases for description validation""" | |||
| from controllers.console.app.app import _validate_description_length | |||
| # Test None input | |||
| assert _validate_description_length(None) is None | |||
| # Test empty string | |||
| assert _validate_description_length("") == "" | |||
| # Test single character | |||
| assert _validate_description_length("a") == "a" | |||
| # Test unicode characters | |||
| unicode_desc = "测试" * 200 # 400 characters in Chinese | |||
| assert _validate_description_length(unicode_desc) == unicode_desc | |||
| # Test unicode over limit | |||
| unicode_over = "测试" * 201 # 402 characters | |||
| with pytest.raises(ValueError): | |||
| _validate_description_length(unicode_over) | |||
| def test_whitespace_handling(self): | |||
| """Test how validation handles whitespace""" | |||
| from controllers.console.app.app import _validate_description_length | |||
| # Test description with spaces | |||
| spaces_400 = " " * 400 | |||
| assert _validate_description_length(spaces_400) == spaces_400 | |||
| # Test description with spaces over limit | |||
| spaces_401 = " " * 401 | |||
| with pytest.raises(ValueError): | |||
| _validate_description_length(spaces_401) | |||
| # Test mixed content | |||
| mixed_400 = "a" * 200 + " " * 200 | |||
| assert _validate_description_length(mixed_400) == mixed_400 | |||
| # Test mixed over limit | |||
| mixed_401 = "a" * 200 + " " * 201 | |||
| with pytest.raises(ValueError): | |||
| _validate_description_length(mixed_401) | |||
| if __name__ == "__main__": | |||
| # Run tests directly | |||
| import traceback | |||
| test_instance = TestAppDescriptionValidationUnit() | |||
| test_methods = [method for method in dir(test_instance) if method.startswith("test_")] | |||
| passed = 0 | |||
| failed = 0 | |||
| for test_method in test_methods: | |||
| try: | |||
| print(f"Running {test_method}...") | |||
| getattr(test_instance, test_method)() | |||
| print(f"✅ {test_method} PASSED") | |||
| passed += 1 | |||
| except Exception as e: | |||
| print(f"❌ {test_method} FAILED: {str(e)}") | |||
| traceback.print_exc() | |||
| failed += 1 | |||
| print(f"\n📊 Test Results: {passed} passed, {failed} failed") | |||
| if failed == 0: | |||
| print("🎉 All tests passed!") | |||
| else: | |||
| print("💥 Some tests failed!") | |||
| sys.exit(1) | |||
| @@ -0,0 +1,252 @@ | |||
| import pytest | |||
| from controllers.console.app.app import _validate_description_length as app_validate | |||
| from controllers.console.datasets.datasets import _validate_description_length as dataset_validate | |||
| from controllers.service_api.dataset.dataset import _validate_description_length as service_dataset_validate | |||
| class TestDescriptionValidationUnit: | |||
| """Unit tests for description validation functions in App and Dataset APIs""" | |||
| def test_app_validate_description_length_valid(self): | |||
| """Test App validation function with valid descriptions""" | |||
| # Empty string should be valid | |||
| assert app_validate("") == "" | |||
| # None should be valid | |||
| assert app_validate(None) is None | |||
| # Short description should be valid | |||
| short_desc = "Short description" | |||
| assert app_validate(short_desc) == short_desc | |||
| # Exactly 400 characters should be valid | |||
| exactly_400 = "x" * 400 | |||
| assert app_validate(exactly_400) == exactly_400 | |||
| # Just under limit should be valid | |||
| just_under = "x" * 399 | |||
| assert app_validate(just_under) == just_under | |||
| def test_app_validate_description_length_invalid(self): | |||
| """Test App validation function with invalid descriptions""" | |||
| # 401 characters should fail | |||
| just_over = "x" * 401 | |||
| with pytest.raises(ValueError) as exc_info: | |||
| app_validate(just_over) | |||
| assert "Description cannot exceed 400 characters." in str(exc_info.value) | |||
| # 500 characters should fail | |||
| way_over = "x" * 500 | |||
| with pytest.raises(ValueError) as exc_info: | |||
| app_validate(way_over) | |||
| assert "Description cannot exceed 400 characters." in str(exc_info.value) | |||
| # 1000 characters should fail | |||
| very_long = "x" * 1000 | |||
| with pytest.raises(ValueError) as exc_info: | |||
| app_validate(very_long) | |||
| assert "Description cannot exceed 400 characters." in str(exc_info.value) | |||
| def test_dataset_validate_description_length_valid(self): | |||
| """Test Dataset validation function with valid descriptions""" | |||
| # Empty string should be valid | |||
| assert dataset_validate("") == "" | |||
| # Short description should be valid | |||
| short_desc = "Short description" | |||
| assert dataset_validate(short_desc) == short_desc | |||
| # Exactly 400 characters should be valid | |||
| exactly_400 = "x" * 400 | |||
| assert dataset_validate(exactly_400) == exactly_400 | |||
| # Just under limit should be valid | |||
| just_under = "x" * 399 | |||
| assert dataset_validate(just_under) == just_under | |||
| def test_dataset_validate_description_length_invalid(self): | |||
| """Test Dataset validation function with invalid descriptions""" | |||
| # 401 characters should fail | |||
| just_over = "x" * 401 | |||
| with pytest.raises(ValueError) as exc_info: | |||
| dataset_validate(just_over) | |||
| assert "Description cannot exceed 400 characters." in str(exc_info.value) | |||
| # 500 characters should fail | |||
| way_over = "x" * 500 | |||
| with pytest.raises(ValueError) as exc_info: | |||
| dataset_validate(way_over) | |||
| assert "Description cannot exceed 400 characters." in str(exc_info.value) | |||
| def test_service_dataset_validate_description_length_valid(self): | |||
| """Test Service Dataset validation function with valid descriptions""" | |||
| # Empty string should be valid | |||
| assert service_dataset_validate("") == "" | |||
| # None should be valid | |||
| assert service_dataset_validate(None) is None | |||
| # Short description should be valid | |||
| short_desc = "Short description" | |||
| assert service_dataset_validate(short_desc) == short_desc | |||
| # Exactly 400 characters should be valid | |||
| exactly_400 = "x" * 400 | |||
| assert service_dataset_validate(exactly_400) == exactly_400 | |||
| # Just under limit should be valid | |||
| just_under = "x" * 399 | |||
| assert service_dataset_validate(just_under) == just_under | |||
| def test_service_dataset_validate_description_length_invalid(self): | |||
| """Test Service Dataset validation function with invalid descriptions""" | |||
| # 401 characters should fail | |||
| just_over = "x" * 401 | |||
| with pytest.raises(ValueError) as exc_info: | |||
| service_dataset_validate(just_over) | |||
| assert "Description cannot exceed 400 characters." in str(exc_info.value) | |||
| # 500 characters should fail | |||
| way_over = "x" * 500 | |||
| with pytest.raises(ValueError) as exc_info: | |||
| service_dataset_validate(way_over) | |||
| assert "Description cannot exceed 400 characters." in str(exc_info.value) | |||
| def test_app_dataset_validation_consistency(self): | |||
| """Test that App and Dataset validation functions behave identically""" | |||
| test_cases = [ | |||
| "", # Empty string | |||
| "Short description", # Normal description | |||
| "x" * 100, # Medium description | |||
| "x" * 400, # Exactly at limit | |||
| ] | |||
| # Test valid cases produce same results | |||
| for test_desc in test_cases: | |||
| assert app_validate(test_desc) == dataset_validate(test_desc) == service_dataset_validate(test_desc) | |||
| # Test invalid cases produce same errors | |||
| invalid_cases = [ | |||
| "x" * 401, # Just over limit | |||
| "x" * 500, # Way over limit | |||
| "x" * 1000, # Very long | |||
| ] | |||
| for invalid_desc in invalid_cases: | |||
| app_error = None | |||
| dataset_error = None | |||
| service_dataset_error = None | |||
| # Capture App validation error | |||
| try: | |||
| app_validate(invalid_desc) | |||
| except ValueError as e: | |||
| app_error = str(e) | |||
| # Capture Dataset validation error | |||
| try: | |||
| dataset_validate(invalid_desc) | |||
| except ValueError as e: | |||
| dataset_error = str(e) | |||
| # Capture Service Dataset validation error | |||
| try: | |||
| service_dataset_validate(invalid_desc) | |||
| except ValueError as e: | |||
| service_dataset_error = str(e) | |||
| # All should produce errors | |||
| assert app_error is not None, f"App validation should fail for {len(invalid_desc)} characters" | |||
| assert dataset_error is not None, f"Dataset validation should fail for {len(invalid_desc)} characters" | |||
| error_msg = f"Service Dataset validation should fail for {len(invalid_desc)} characters" | |||
| assert service_dataset_error is not None, error_msg | |||
| # Errors should be identical | |||
| error_msg = f"Error messages should be identical for {len(invalid_desc)} characters" | |||
| assert app_error == dataset_error == service_dataset_error, error_msg | |||
| assert app_error == "Description cannot exceed 400 characters." | |||
| def test_boundary_values(self): | |||
| """Test boundary values around the 400 character limit""" | |||
| boundary_tests = [ | |||
| (0, True), # Empty | |||
| (1, True), # Minimum | |||
| (399, True), # Just under limit | |||
| (400, True), # Exactly at limit | |||
| (401, False), # Just over limit | |||
| (402, False), # Over limit | |||
| (500, False), # Way over limit | |||
| ] | |||
| for length, should_pass in boundary_tests: | |||
| test_desc = "x" * length | |||
| if should_pass: | |||
| # Should not raise exception | |||
| assert app_validate(test_desc) == test_desc | |||
| assert dataset_validate(test_desc) == test_desc | |||
| assert service_dataset_validate(test_desc) == test_desc | |||
| else: | |||
| # Should raise ValueError | |||
| with pytest.raises(ValueError): | |||
| app_validate(test_desc) | |||
| with pytest.raises(ValueError): | |||
| dataset_validate(test_desc) | |||
| with pytest.raises(ValueError): | |||
| service_dataset_validate(test_desc) | |||
| def test_special_characters(self): | |||
| """Test validation with special characters, Unicode, etc.""" | |||
| # Unicode characters | |||
| unicode_desc = "测试描述" * 100 # Chinese characters | |||
| if len(unicode_desc) <= 400: | |||
| assert app_validate(unicode_desc) == unicode_desc | |||
| assert dataset_validate(unicode_desc) == unicode_desc | |||
| assert service_dataset_validate(unicode_desc) == unicode_desc | |||
| # Special characters | |||
| special_desc = "Special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?" * 10 | |||
| if len(special_desc) <= 400: | |||
| assert app_validate(special_desc) == special_desc | |||
| assert dataset_validate(special_desc) == special_desc | |||
| assert service_dataset_validate(special_desc) == special_desc | |||
| # Mixed content | |||
| mixed_desc = "Mixed content: 测试 123 !@# " * 15 | |||
| if len(mixed_desc) <= 400: | |||
| assert app_validate(mixed_desc) == mixed_desc | |||
| assert dataset_validate(mixed_desc) == mixed_desc | |||
| assert service_dataset_validate(mixed_desc) == mixed_desc | |||
| elif len(mixed_desc) > 400: | |||
| with pytest.raises(ValueError): | |||
| app_validate(mixed_desc) | |||
| with pytest.raises(ValueError): | |||
| dataset_validate(mixed_desc) | |||
| with pytest.raises(ValueError): | |||
| service_dataset_validate(mixed_desc) | |||
| def test_whitespace_handling(self): | |||
| """Test validation with various whitespace scenarios""" | |||
| # Leading/trailing whitespace | |||
| whitespace_desc = " Description with whitespace " | |||
| if len(whitespace_desc) <= 400: | |||
| assert app_validate(whitespace_desc) == whitespace_desc | |||
| assert dataset_validate(whitespace_desc) == whitespace_desc | |||
| assert service_dataset_validate(whitespace_desc) == whitespace_desc | |||
| # Newlines and tabs | |||
| multiline_desc = "Line 1\nLine 2\tTabbed content" | |||
| if len(multiline_desc) <= 400: | |||
| assert app_validate(multiline_desc) == multiline_desc | |||
| assert dataset_validate(multiline_desc) == multiline_desc | |||
| assert service_dataset_validate(multiline_desc) == multiline_desc | |||
| # Only whitespace over limit | |||
| only_spaces = " " * 401 | |||
| with pytest.raises(ValueError): | |||
| app_validate(only_spaces) | |||
| with pytest.raises(ValueError): | |||
| dataset_validate(only_spaces) | |||
| with pytest.raises(ValueError): | |||
| service_dataset_validate(only_spaces) | |||
| @@ -0,0 +1,97 @@ | |||
| /** | |||
| * Description Validation Test | |||
| * | |||
| * Tests for the 400-character description validation across App and Dataset | |||
| * creation and editing workflows to ensure consistent validation behavior. | |||
| */ | |||
| describe('Description Validation Logic', () => { | |||
| // Simulate backend validation function | |||
| const validateDescriptionLength = (description?: string | null) => { | |||
| if (description && description.length > 400) | |||
| throw new Error('Description cannot exceed 400 characters.') | |||
| return description | |||
| } | |||
| describe('Backend Validation Function', () => { | |||
| test('allows description within 400 characters', () => { | |||
| const validDescription = 'x'.repeat(400) | |||
| expect(() => validateDescriptionLength(validDescription)).not.toThrow() | |||
| expect(validateDescriptionLength(validDescription)).toBe(validDescription) | |||
| }) | |||
| test('allows empty description', () => { | |||
| expect(() => validateDescriptionLength('')).not.toThrow() | |||
| expect(() => validateDescriptionLength(null)).not.toThrow() | |||
| expect(() => validateDescriptionLength(undefined)).not.toThrow() | |||
| }) | |||
| test('rejects description exceeding 400 characters', () => { | |||
| const invalidDescription = 'x'.repeat(401) | |||
| expect(() => validateDescriptionLength(invalidDescription)).toThrow( | |||
| 'Description cannot exceed 400 characters.', | |||
| ) | |||
| }) | |||
| }) | |||
| describe('Backend Validation Consistency', () => { | |||
| test('App and Dataset have consistent validation limits', () => { | |||
| const maxLength = 400 | |||
| const validDescription = 'x'.repeat(maxLength) | |||
| const invalidDescription = 'x'.repeat(maxLength + 1) | |||
| // Both should accept exactly 400 characters | |||
| expect(validDescription.length).toBe(400) | |||
| expect(() => validateDescriptionLength(validDescription)).not.toThrow() | |||
| // Both should reject 401 characters | |||
| expect(invalidDescription.length).toBe(401) | |||
| expect(() => validateDescriptionLength(invalidDescription)).toThrow() | |||
| }) | |||
| test('validation error messages are consistent', () => { | |||
| const expectedErrorMessage = 'Description cannot exceed 400 characters.' | |||
| // This would be the error message from both App and Dataset backend validation | |||
| expect(expectedErrorMessage).toBe('Description cannot exceed 400 characters.') | |||
| const invalidDescription = 'x'.repeat(401) | |||
| try { | |||
| validateDescriptionLength(invalidDescription) | |||
| } | |||
| catch (error) { | |||
| expect((error as Error).message).toBe(expectedErrorMessage) | |||
| } | |||
| }) | |||
| }) | |||
| describe('Character Length Edge Cases', () => { | |||
| const testCases = [ | |||
| { length: 0, shouldPass: true, description: 'empty description' }, | |||
| { length: 1, shouldPass: true, description: '1 character' }, | |||
| { length: 399, shouldPass: true, description: '399 characters' }, | |||
| { length: 400, shouldPass: true, description: '400 characters (boundary)' }, | |||
| { length: 401, shouldPass: false, description: '401 characters (over limit)' }, | |||
| { length: 500, shouldPass: false, description: '500 characters' }, | |||
| { length: 1000, shouldPass: false, description: '1000 characters' }, | |||
| ] | |||
| testCases.forEach(({ length, shouldPass, description }) => { | |||
| test(`handles ${description} correctly`, () => { | |||
| const testDescription = length > 0 ? 'x'.repeat(length) : '' | |||
| expect(testDescription.length).toBe(length) | |||
| if (shouldPass) { | |||
| expect(() => validateDescriptionLength(testDescription)).not.toThrow() | |||
| expect(validateDescriptionLength(testDescription)).toBe(testDescription) | |||
| } | |||
| else { | |||
| expect(() => validateDescriptionLength(testDescription)).toThrow( | |||
| 'Description cannot exceed 400 characters.', | |||
| ) | |||
| } | |||
| }) | |||
| }) | |||
| }) | |||
| }) | |||
| @@ -82,8 +82,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps) | |||
| localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') | |||
| getRedirection(isCurrentWorkspaceEditor, app, push) | |||
| } | |||
| catch { | |||
| notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) | |||
| catch (e: any) { | |||
| notify({ | |||
| type: 'error', | |||
| message: e.message || t('app.newApp.appCreateFailed'), | |||
| }) | |||
| } | |||
| isCreatingRef.current = false | |||
| }, [name, notify, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor]) | |||
| @@ -117,8 +117,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { | |||
| if (onRefresh) | |||
| onRefresh() | |||
| } | |||
| catch { | |||
| notify({ type: 'error', message: t('app.editFailed') }) | |||
| catch (e: any) { | |||
| notify({ | |||
| type: 'error', | |||
| message: e.message || t('app.editFailed'), | |||
| }) | |||
| } | |||
| }, [app.id, notify, onRefresh, t]) | |||
| @@ -364,7 +367,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { | |||
| </div> | |||
| <div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'> | |||
| <div | |||
| className={cn(tags.length ? 'line-clamp-2' : 'line-clamp-4', 'group-hover:line-clamp-2')} | |||
| className='line-clamp-2' | |||
| title={app.description} | |||
| > | |||
| {app.description} | |||