2025-12-22 08:35:22 +00:00
import type { Mock } from 'vitest'
2026-02-13 05:21:09 +00:00
import type { App } from '@/types/app'
2025-12-16 02:42:34 +00:00
import { fireEvent , render , screen , waitFor } from '@testing-library/react'
2025-12-23 10:02:10 +00:00
import * as React from 'react'
2025-12-16 02:42:34 +00:00
import { AccessMode } from '@/models/access-control'
2025-12-23 08:58:55 +00:00
import * as appsService from '@/service/apps'
import * as exploreService from '@/service/explore'
import * as workflowService from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
2026-02-13 05:21:09 +00:00
import AppCard from '../app-card'
2025-12-16 02:42:34 +00:00
// Mock next/navigation
2025-12-22 08:35:22 +00:00
const mockPush = vi . fn ( )
2026-03-18 04:57:03 +00:00
vi . mock ( '@/next/navigation' , ( ) = > ( {
2025-12-16 02:42:34 +00:00
useRouter : ( ) = > ( {
push : mockPush ,
} ) ,
} ) )
// Mock use-context-selector with stable mockNotify reference for tracking calls
// Include createContext for components that use it (like Toast)
2025-12-22 08:35:22 +00:00
const mockNotify = vi . fn ( )
2025-12-23 10:02:10 +00:00
vi . mock ( 'use-context-selector' , ( ) = > ( {
2026-02-13 05:21:09 +00:00
createContext : < T , > ( defaultValue : T ) = > React . createContext ( defaultValue ) ,
2025-12-23 10:02:10 +00:00
useContext : ( ) = > ( {
notify : mockNotify ,
} ) ,
2026-02-13 05:21:09 +00:00
useContextSelector : ( _context : unknown , selector : ( state : Record < string , unknown > ) = > unknown ) = > selector ( {
2025-12-23 10:02:10 +00:00
notify : mockNotify ,
} ) ,
} ) )
2025-12-16 02:42:34 +00:00
// Mock app context
2025-12-22 08:35:22 +00:00
vi . mock ( '@/context/app-context' , ( ) = > ( {
2025-12-16 02:42:34 +00:00
useAppContext : ( ) = > ( {
isCurrentWorkspaceEditor : true ,
} ) ,
} ) )
// Mock provider context
2025-12-22 08:35:22 +00:00
const mockOnPlanInfoChanged = vi . fn ( )
vi . mock ( '@/context/provider-context' , ( ) = > ( {
2025-12-16 02:42:34 +00:00
useProviderContext : ( ) = > ( {
onPlanInfoChanged : mockOnPlanInfoChanged ,
} ) ,
} ) )
2025-12-19 07:21:21 +00:00
// Mock global public store - allow dynamic configuration
let mockWebappAuthEnabled = false
2025-12-22 08:35:22 +00:00
vi . mock ( '@/context/global-public-context' , ( ) = > ( {
2026-02-13 05:21:09 +00:00
useGlobalPublicStore : ( selector : ( s : Record < string , unknown > ) = > unknown ) = > selector ( {
2025-12-16 02:42:34 +00:00
systemFeatures : {
2025-12-19 07:21:21 +00:00
webapp_auth : { enabled : mockWebappAuthEnabled } ,
2025-12-16 02:42:34 +00:00
branding : { enabled : false } ,
} ,
} ) ,
} ) )
2025-12-22 08:35:22 +00:00
vi . mock ( '@/service/apps' , ( ) = > ( {
deleteApp : vi.fn ( ( ) = > Promise . resolve ( ) ) ,
updateAppInfo : vi.fn ( ( ) = > Promise . resolve ( ) ) ,
copyApp : vi.fn ( ( ) = > Promise . resolve ( { id : 'new-app-id' } ) ) ,
exportAppConfig : vi.fn ( ( ) = > Promise . resolve ( { data : 'yaml: content' } ) ) ,
2025-12-16 02:42:34 +00:00
} ) )
2026-03-04 05:56:27 +00:00
const mockDeleteAppMutation = vi . fn ( ( ) = > Promise . resolve ( ) )
let mockDeleteMutationPending = false
vi . mock ( '@/service/use-apps' , ( ) = > ( {
useDeleteAppMutation : ( ) = > ( {
mutateAsync : mockDeleteAppMutation ,
isPending : mockDeleteMutationPending ,
} ) ,
} ) )
2025-12-22 08:35:22 +00:00
vi . mock ( '@/service/workflow' , ( ) = > ( {
fetchWorkflowDraft : vi.fn ( ( ) = > Promise . resolve ( { environment_variables : [ ] } ) ) ,
2025-12-16 02:42:34 +00:00
} ) )
2025-12-22 08:35:22 +00:00
vi . mock ( '@/service/explore' , ( ) = > ( {
fetchInstalledAppList : vi.fn ( ( ) = > Promise . resolve ( { installed_apps : [ { id : 'installed-1' } ] } ) ) ,
2025-12-16 02:42:34 +00:00
} ) )
2025-12-22 08:35:22 +00:00
vi . mock ( '@/service/access-control' , ( ) = > ( {
2025-12-16 02:42:34 +00:00
useGetUserCanAccessApp : ( ) = > ( {
data : { result : true } ,
isLoading : false ,
} ) ,
} ) )
// Mock hooks
2025-12-22 08:35:22 +00:00
const mockOpenAsyncWindow = vi . fn ( )
vi . mock ( '@/hooks/use-async-window-open' , ( ) = > ( {
2025-12-19 07:21:21 +00:00
useAsyncWindowOpen : ( ) = > mockOpenAsyncWindow ,
2025-12-16 02:42:34 +00:00
} ) )
// Mock utils
2025-12-22 08:35:22 +00:00
const { mockGetRedirection } = vi . hoisted ( ( ) = > ( {
mockGetRedirection : vi.fn ( ) ,
2025-12-16 02:42:34 +00:00
} ) )
2025-12-22 08:35:22 +00:00
vi . mock ( '@/utils/app-redirection' , ( ) = > ( {
getRedirection : mockGetRedirection ,
} ) )
vi . mock ( '@/utils/var' , ( ) = > ( {
2025-12-16 02:42:34 +00:00
basePath : '' ,
} ) )
2025-12-22 08:35:22 +00:00
vi . mock ( '@/utils/time' , ( ) = > ( {
2025-12-16 02:42:34 +00:00
formatTime : ( ) = > 'Jan 1, 2024' ,
} ) )
// Mock dynamic imports
2026-03-18 04:57:03 +00:00
vi . mock ( '@/next/dynamic' , ( ) = > ( {
2026-02-13 05:21:09 +00:00
default : ( importFn : ( ) = > Promise < unknown > ) = > {
2025-12-23 10:02:10 +00:00
const fnString = importFn . toString ( )
if ( fnString . includes ( 'create-app-modal' ) || fnString . includes ( 'explore/create-app-modal' ) ) {
2026-02-13 05:21:09 +00:00
return function MockEditAppModal ( { show , onHide , onConfirm } : { show : boolean , onHide : ( ) = > void , onConfirm ? : ( data : Record < string , unknown > ) = > void } ) {
2025-12-23 10:02:10 +00:00
if ( ! show )
return null
return React . createElement ( 'div' , { 'data-testid' : 'edit-app-modal' } , React . createElement ( 'button' , { 'onClick' : onHide , 'data-testid' : 'close-edit-modal' } , 'Close' ) , React . createElement ( 'button' , {
'onClick' : ( ) = > onConfirm ? . ( {
name : 'Updated App' ,
icon_type : 'emoji' ,
icon : '🎯' ,
icon_background : '#FFEAD5' ,
description : 'Updated description' ,
use_icon_as_answer_icon : false ,
max_active_requests : null ,
} ) ,
'data-testid' : 'confirm-edit-modal' ,
} , 'Confirm' ) )
2025-12-16 02:42:34 +00:00
}
2025-12-23 10:02:10 +00:00
}
if ( fnString . includes ( 'duplicate-modal' ) ) {
2026-02-13 05:21:09 +00:00
return function MockDuplicateAppModal ( { show , onHide , onConfirm } : { show : boolean , onHide : ( ) = > void , onConfirm ? : ( data : Record < string , unknown > ) = > void } ) {
2025-12-23 10:02:10 +00:00
if ( ! show )
return null
return React . createElement ( 'div' , { 'data-testid' : 'duplicate-modal' } , React . createElement ( 'button' , { 'onClick' : onHide , 'data-testid' : 'close-duplicate-modal' } , 'Close' ) , React . createElement ( 'button' , {
'onClick' : ( ) = > onConfirm ? . ( {
name : 'Copied App' ,
icon_type : 'emoji' ,
icon : '📋' ,
icon_background : '#E4FBCC' ,
} ) ,
'data-testid' : 'confirm-duplicate-modal' ,
} , 'Confirm' ) )
2025-12-16 02:42:34 +00:00
}
2025-12-23 10:02:10 +00:00
}
if ( fnString . includes ( 'switch-app-modal' ) ) {
2026-02-13 05:21:09 +00:00
return function MockSwitchAppModal ( { show , onClose , onSuccess } : { show : boolean , onClose : ( ) = > void , onSuccess : ( ) = > void } ) {
2025-12-23 10:02:10 +00:00
if ( ! show )
return null
return React . createElement ( 'div' , { 'data-testid' : 'switch-modal' } , React . createElement ( 'button' , { 'onClick' : onClose , 'data-testid' : 'close-switch-modal' } , 'Close' ) , React . createElement ( 'button' , { 'onClick' : onSuccess , 'data-testid' : 'confirm-switch-modal' } , 'Switch' ) )
2025-12-16 02:42:34 +00:00
}
2025-12-23 10:02:10 +00:00
}
if ( fnString . includes ( 'dsl-export-confirm-modal' ) ) {
2026-02-13 05:21:09 +00:00
return function MockDSLExportModal ( { onClose , onConfirm } : { onClose ? : ( ) = > void , onConfirm ? : ( withSecrets : boolean ) = > void } ) {
2025-12-23 10:02:10 +00:00
return React . createElement ( 'div' , { 'data-testid' : 'dsl-export-modal' } , React . createElement ( 'button' , { 'onClick' : ( ) = > onClose ? . ( ) , 'data-testid' : 'close-dsl-export' } , 'Close' ) , React . createElement ( 'button' , { 'onClick' : ( ) = > onConfirm ? . ( true ) , 'data-testid' : 'confirm-dsl-export' } , 'Export with secrets' ) , React . createElement ( 'button' , { 'onClick' : ( ) = > onConfirm ? . ( false ) , 'data-testid' : 'confirm-dsl-export-no-secrets' } , 'Export without secrets' ) )
2025-12-16 02:42:34 +00:00
}
2025-12-23 10:02:10 +00:00
}
if ( fnString . includes ( 'app-access-control' ) ) {
2026-02-13 05:21:09 +00:00
return function MockAccessControl ( { onClose , onConfirm } : { onClose : ( ) = > void , onConfirm : ( ) = > void } ) {
2025-12-23 10:02:10 +00:00
return React . createElement ( 'div' , { 'data-testid' : 'access-control-modal' } , React . createElement ( 'button' , { 'onClick' : onClose , 'data-testid' : 'close-access-control' } , 'Close' ) , React . createElement ( 'button' , { 'onClick' : onConfirm , 'data-testid' : 'confirm-access-control' } , 'Confirm' ) )
2025-12-16 02:42:34 +00:00
}
2025-12-23 10:02:10 +00:00
}
return ( ) = > null
} ,
} ) )
2025-12-16 02:42:34 +00:00
2025-12-19 07:21:21 +00:00
// Popover uses @headlessui/react portals - mock for controlled interaction testing
2025-12-22 08:35:22 +00:00
vi . mock ( '@/app/components/base/popover' , ( ) = > {
2026-02-13 05:21:09 +00:00
type PopoverHtmlContent = React . ReactNode | ( ( state : { open : boolean , onClose : ( ) = > void , onClick : ( ) = > void } ) = > React . ReactNode )
type MockPopoverProps = { htmlContent : PopoverHtmlContent , btnElement : React.ReactNode , btnClassName? : string | ( ( open : boolean ) = > string ) }
const MockPopover = ( { htmlContent , btnElement , btnClassName } : MockPopoverProps ) = > {
2025-12-16 02:42:34 +00:00
const [ isOpen , setIsOpen ] = React . useState ( false )
const computedClassName = typeof btnClassName === 'function' ? btnClassName ( isOpen ) : ''
2025-12-23 08:58:55 +00:00
return React . createElement ( 'div' , { 'data-testid' : 'custom-popover' , 'className' : computedClassName } , React . createElement ( 'div' , {
'onClick' : ( ) = > setIsOpen ( ! isOpen ) ,
'data-testid' : 'popover-trigger' ,
} , btnElement ) , isOpen && React . createElement ( 'div' , {
'data-testid' : 'popover-content' ,
'onMouseLeave' : ( ) = > setIsOpen ( false ) ,
} , typeof htmlContent === 'function' ? htmlContent ( { open : isOpen , onClose : ( ) = > setIsOpen ( false ) , onClick : ( ) = > setIsOpen ( false ) } ) : htmlContent ) )
2025-12-16 02:42:34 +00:00
}
return { __esModule : true , default : MockPopover }
} )
2025-12-19 07:21:21 +00:00
// Tooltip uses portals - minimal mock preserving popup content as title attribute
2025-12-22 08:35:22 +00:00
vi . mock ( '@/app/components/base/tooltip' , ( ) = > ( {
2026-02-13 05:21:09 +00:00
default : ( { children , popupContent } : { children : React.ReactNode , popupContent : React.ReactNode } ) = > React . createElement ( 'div' , { title : popupContent } , children ) ,
2025-12-16 02:42:34 +00:00
} ) )
2025-12-19 07:21:21 +00:00
// TagSelector has API dependency (service/tag) - mock for isolated testing
2025-12-22 08:35:22 +00:00
vi . mock ( '@/app/components/base/tag-management/selector' , ( ) = > ( {
2026-02-13 05:21:09 +00:00
default : ( { tags } : { tags ? : { id : string , name : string } [ ] } ) = > {
return React . createElement ( 'div' , { 'aria-label' : 'tag-selector' } , tags ? . map ( ( tag : { id : string , name : string } ) = > React . createElement ( 'span' , { key : tag.id } , tag . name ) ) )
2025-12-16 02:42:34 +00:00
} ,
} ) )
2025-12-19 07:21:21 +00:00
// AppTypeIcon has complex icon mapping - mock for focused component testing
2025-12-22 08:35:22 +00:00
vi . mock ( '@/app/components/app/type-selector' , ( ) = > ( {
2025-12-16 02:42:34 +00:00
AppTypeIcon : ( ) = > React . createElement ( 'div' , { 'data-testid' : 'app-type-icon' } ) ,
} ) )
2026-02-13 05:21:09 +00:00
const createMockApp = ( overrides : Partial < App > = { } ) : App = > ( {
2025-12-16 02:42:34 +00:00
id : 'test-app-id' ,
name : 'Test App' ,
description : 'Test app description' ,
mode : AppModeEnum.CHAT ,
icon : '🤖' ,
icon_type : 'emoji' as const ,
icon_background : '#FFEAD5' ,
icon_url : null ,
author_name : 'Test Author' ,
created_at : 1704067200 ,
updated_at : 1704153600 ,
tags : [ ] ,
use_icon_as_answer_icon : false ,
max_active_requests : null ,
access_mode : AccessMode.PUBLIC ,
has_draft_trigger : false ,
enable_site : true ,
enable_api : true ,
api_rpm : 60 ,
api_rph : 3600 ,
is_demo : false ,
. . . overrides ,
2026-02-13 05:21:09 +00:00
} as App )
2025-12-16 02:42:34 +00:00
describe ( 'AppCard' , ( ) = > {
const mockApp = createMockApp ( )
2025-12-22 08:35:22 +00:00
const mockOnRefresh = vi . fn ( )
2025-12-16 02:42:34 +00:00
beforeEach ( ( ) = > {
2025-12-22 08:35:22 +00:00
vi . clearAllMocks ( )
2025-12-19 07:21:21 +00:00
mockOpenAsyncWindow . mockReset ( )
mockWebappAuthEnabled = false
2026-03-04 05:56:27 +00:00
mockDeleteMutationPending = false
2025-12-16 02:42:34 +00:00
} )
describe ( 'Rendering' , ( ) = > {
it ( 'should render without crashing' , ( ) = > {
render ( < AppCard app = { mockApp } / > )
// Use title attribute to target specific element
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
} )
it ( 'should display app name' , ( ) = > {
render ( < AppCard app = { mockApp } / > )
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
} )
it ( 'should display app description' , ( ) = > {
render ( < AppCard app = { mockApp } / > )
expect ( screen . getByTitle ( 'Test app description' ) ) . toBeInTheDocument ( )
} )
it ( 'should display author name' , ( ) = > {
render ( < AppCard app = { mockApp } / > )
expect ( screen . getByTitle ( 'Test Author' ) ) . toBeInTheDocument ( )
} )
it ( 'should render app icon' , ( ) = > {
// AppIcon component renders the emoji icon from app data
const { container } = render ( < AppCard app = { mockApp } / > )
// Check that the icon container is rendered (AppIcon renders within the card)
const iconElement = container . querySelector ( '[class*="icon"]' ) || container . querySelector ( 'img' )
expect ( iconElement || screen . getByText ( mockApp . icon ) ) . toBeTruthy ( )
} )
it ( 'should render app type icon' , ( ) = > {
render ( < AppCard app = { mockApp } / > )
expect ( screen . getByTestId ( 'app-type-icon' ) ) . toBeInTheDocument ( )
} )
it ( 'should display formatted edit time' , ( ) = > {
render ( < AppCard app = { mockApp } / > )
expect ( screen . getByText ( /edited/i ) ) . toBeInTheDocument ( )
} )
} )
describe ( 'Props' , ( ) = > {
it ( 'should handle different app modes' , ( ) = > {
const workflowApp = { . . . mockApp , mode : AppModeEnum.WORKFLOW }
render ( < AppCard app = { workflowApp } / > )
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
} )
it ( 'should handle app with tags' , ( ) = > {
const appWithTags = {
. . . mockApp ,
tags : [ { id : 'tag1' , name : 'Tag 1' , type : 'app' , binding_count : 0 } ] ,
}
render ( < AppCard app = { appWithTags } / > )
// Verify the tag selector component renders
expect ( screen . getByLabelText ( 'tag-selector' ) ) . toBeInTheDocument ( )
} )
it ( 'should render with onRefresh callback' , ( ) = > {
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
} )
} )
describe ( 'Access Mode Icons' , ( ) = > {
it ( 'should show public icon for public access mode' , ( ) = > {
const publicApp = { . . . mockApp , access_mode : AccessMode.PUBLIC }
const { container } = render ( < AppCard app = { publicApp } / > )
const tooltip = container . querySelector ( '[title="app.accessItemsDescription.anyone"]' )
expect ( tooltip ) . toBeInTheDocument ( )
} )
it ( 'should show lock icon for specific groups access mode' , ( ) = > {
const specificApp = { . . . mockApp , access_mode : AccessMode.SPECIFIC_GROUPS_MEMBERS }
const { container } = render ( < AppCard app = { specificApp } / > )
const tooltip = container . querySelector ( '[title="app.accessItemsDescription.specific"]' )
expect ( tooltip ) . toBeInTheDocument ( )
} )
it ( 'should show organization icon for organization access mode' , ( ) = > {
const orgApp = { . . . mockApp , access_mode : AccessMode.ORGANIZATION }
const { container } = render ( < AppCard app = { orgApp } / > )
const tooltip = container . querySelector ( '[title="app.accessItemsDescription.organization"]' )
expect ( tooltip ) . toBeInTheDocument ( )
} )
it ( 'should show external icon for external access mode' , ( ) = > {
const externalApp = { . . . mockApp , access_mode : AccessMode.EXTERNAL_MEMBERS }
const { container } = render ( < AppCard app = { externalApp } / > )
const tooltip = container . querySelector ( '[title="app.accessItemsDescription.external"]' )
expect ( tooltip ) . toBeInTheDocument ( )
} )
} )
describe ( 'Card Interaction' , ( ) = > {
it ( 'should handle card click' , ( ) = > {
render ( < AppCard app = { mockApp } / > )
const card = screen . getByTitle ( 'Test App' ) . closest ( '[class*="cursor-pointer"]' )
expect ( card ) . toBeInTheDocument ( )
} )
it ( 'should call getRedirection on card click' , ( ) = > {
render ( < AppCard app = { mockApp } / > )
const card = screen . getByTitle ( 'Test App' ) . closest ( '[class*="cursor-pointer"]' ) !
fireEvent . click ( card )
2025-12-22 08:35:22 +00:00
expect ( mockGetRedirection ) . toHaveBeenCalledWith ( true , mockApp , mockPush )
2025-12-16 02:42:34 +00:00
} )
} )
describe ( 'Operations Menu' , ( ) = > {
it ( 'should render operations popover' , ( ) = > {
render ( < AppCard app = { mockApp } / > )
expect ( screen . getByTestId ( 'custom-popover' ) ) . toBeInTheDocument ( )
} )
it ( 'should show edit option when popover is opened' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.editApp' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should show duplicate option when popover is opened' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.duplicate' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should show export option when popover is opened' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.export' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should show delete option when popover is opened' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'common.operation.delete' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should show switch option for chat mode apps' , async ( ) = > {
const chatApp = { . . . mockApp , mode : AppModeEnum.CHAT }
render ( < AppCard app = { chatApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( /switch/i ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should show switch option for completion mode apps' , async ( ) = > {
const completionApp = { . . . mockApp , mode : AppModeEnum.COMPLETION }
render ( < AppCard app = { completionApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( /switch/i ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should not show switch option for workflow mode apps' , async ( ) = > {
const workflowApp = { . . . mockApp , mode : AppModeEnum.WORKFLOW }
render ( < AppCard app = { workflowApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . queryByText ( /switch/i ) ) . not . toBeInTheDocument ( )
} )
} )
} )
describe ( 'Modal Interactions' , ( ) = > {
it ( 'should open edit modal when edit button is clicked' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
const editButton = screen . getByText ( 'app.editApp' )
fireEvent . click ( editButton )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'edit-app-modal' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should open duplicate modal when duplicate button is clicked' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
const duplicateButton = screen . getByText ( 'app.duplicate' )
fireEvent . click ( duplicateButton )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'duplicate-modal' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should open confirm dialog when delete button is clicked' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
2026-03-04 05:56:27 +00:00
fireEvent . click ( await screen . findByRole ( 'button' , { name : 'common.operation.delete' } ) )
expect ( await screen . findByRole ( 'alertdialog' ) ) . toBeInTheDocument ( )
2025-12-16 02:42:34 +00:00
} )
it ( 'should close confirm dialog when cancel is clicked' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
2026-03-04 05:56:27 +00:00
fireEvent . click ( await screen . findByRole ( 'button' , { name : 'common.operation.delete' } ) )
expect ( await screen . findByRole ( 'alertdialog' ) ) . toBeInTheDocument ( )
fireEvent . click ( screen . getByRole ( 'button' , { name : 'common.operation.cancel' } ) )
2025-12-16 02:42:34 +00:00
await waitFor ( ( ) = > {
2026-03-04 05:56:27 +00:00
expect ( screen . queryByRole ( 'alertdialog' ) ) . not . toBeInTheDocument ( )
2025-12-16 02:42:34 +00:00
} )
} )
2025-12-19 07:21:21 +00:00
it ( 'should close edit modal when onHide is called' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.editApp' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'edit-app-modal' ) ) . toBeInTheDocument ( )
} )
// Click close button to trigger onHide
fireEvent . click ( screen . getByTestId ( 'close-edit-modal' ) )
await waitFor ( ( ) = > {
expect ( screen . queryByTestId ( 'edit-app-modal' ) ) . not . toBeInTheDocument ( )
} )
} )
it ( 'should close duplicate modal when onHide is called' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.duplicate' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'duplicate-modal' ) ) . toBeInTheDocument ( )
} )
// Click close button to trigger onHide
fireEvent . click ( screen . getByTestId ( 'close-duplicate-modal' ) )
await waitFor ( ( ) = > {
expect ( screen . queryByTestId ( 'duplicate-modal' ) ) . not . toBeInTheDocument ( )
} )
} )
2025-12-16 02:42:34 +00:00
} )
describe ( 'Styling' , ( ) = > {
it ( 'should have correct card container styling' , ( ) = > {
const { container } = render ( < AppCard app = { mockApp } / > )
const card = container . querySelector ( '[class*="h-[160px]"]' )
expect ( card ) . toBeInTheDocument ( )
} )
it ( 'should have rounded corners' , ( ) = > {
const { container } = render ( < AppCard app = { mockApp } / > )
const card = container . querySelector ( '[class*="rounded-xl"]' )
expect ( card ) . toBeInTheDocument ( )
} )
} )
describe ( 'API Callbacks' , ( ) = > {
it ( 'should call deleteApp API when confirming delete' , async ( ) = > {
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
// Open popover and click delete
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
2026-03-04 05:56:27 +00:00
fireEvent . click ( await screen . findByRole ( 'button' , { name : 'common.operation.delete' } ) )
expect ( await screen . findByRole ( 'alertdialog' ) ) . toBeInTheDocument ( )
fireEvent . click ( screen . getByRole ( 'button' , { name : 'common.operation.confirm' } ) )
2025-12-16 02:42:34 +00:00
await waitFor ( ( ) = > {
2026-03-04 05:56:27 +00:00
expect ( mockDeleteAppMutation ) . toHaveBeenCalled ( )
2025-12-16 02:42:34 +00:00
} )
} )
2026-03-04 05:56:27 +00:00
it ( 'should not call onRefresh after successful delete' , async ( ) = > {
2025-12-16 02:42:34 +00:00
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
2026-03-04 05:56:27 +00:00
fireEvent . click ( await screen . findByRole ( 'button' , { name : 'common.operation.delete' } ) )
expect ( await screen . findByRole ( 'alertdialog' ) ) . toBeInTheDocument ( )
fireEvent . click ( screen . getByRole ( 'button' , { name : 'common.operation.confirm' } ) )
2025-12-16 02:42:34 +00:00
await waitFor ( ( ) = > {
2026-03-04 05:56:27 +00:00
expect ( mockDeleteAppMutation ) . toHaveBeenCalled ( )
2025-12-16 02:42:34 +00:00
} )
2026-03-04 05:56:27 +00:00
expect ( mockOnRefresh ) . not . toHaveBeenCalled ( )
2025-12-16 02:42:34 +00:00
} )
it ( 'should handle delete failure' , async ( ) = > {
2026-03-04 05:56:27 +00:00
; ( mockDeleteAppMutation as Mock ) . mockRejectedValueOnce ( new Error ( 'Delete failed' ) )
2025-12-16 02:42:34 +00:00
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
2026-03-04 05:56:27 +00:00
fireEvent . click ( await screen . findByRole ( 'button' , { name : 'common.operation.delete' } ) )
expect ( await screen . findByRole ( 'alertdialog' ) ) . toBeInTheDocument ( )
fireEvent . click ( screen . getByRole ( 'button' , { name : 'common.operation.confirm' } ) )
2025-12-16 02:42:34 +00:00
await waitFor ( ( ) = > {
2026-03-04 05:56:27 +00:00
expect ( mockDeleteAppMutation ) . toHaveBeenCalled ( )
2025-12-16 02:42:34 +00:00
expect ( mockNotify ) . toHaveBeenCalledWith ( { type : 'error' , message : expect.stringContaining ( 'Delete failed' ) } )
} )
} )
it ( 'should call updateAppInfo API when editing app' , async ( ) = > {
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.editApp' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'edit-app-modal' ) ) . toBeInTheDocument ( )
} )
fireEvent . click ( screen . getByTestId ( 'confirm-edit-modal' ) )
await waitFor ( ( ) = > {
expect ( appsService . updateAppInfo ) . toHaveBeenCalled ( )
} )
} )
it ( 'should call copyApp API when duplicating app' , async ( ) = > {
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.duplicate' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'duplicate-modal' ) ) . toBeInTheDocument ( )
} )
fireEvent . click ( screen . getByTestId ( 'confirm-duplicate-modal' ) )
await waitFor ( ( ) = > {
expect ( appsService . copyApp ) . toHaveBeenCalled ( )
} )
} )
it ( 'should call onPlanInfoChanged after successful duplication' , async ( ) = > {
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.duplicate' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'duplicate-modal' ) ) . toBeInTheDocument ( )
} )
fireEvent . click ( screen . getByTestId ( 'confirm-duplicate-modal' ) )
await waitFor ( ( ) = > {
expect ( mockOnPlanInfoChanged ) . toHaveBeenCalled ( )
} )
} )
it ( 'should handle copy failure' , async ( ) = > {
2025-12-22 08:35:22 +00:00
( appsService . copyApp as Mock ) . mockRejectedValueOnce ( new Error ( 'Copy failed' ) )
2025-12-16 02:42:34 +00:00
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.duplicate' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'duplicate-modal' ) ) . toBeInTheDocument ( )
} )
fireEvent . click ( screen . getByTestId ( 'confirm-duplicate-modal' ) )
await waitFor ( ( ) = > {
expect ( appsService . copyApp ) . toHaveBeenCalled ( )
expect ( mockNotify ) . toHaveBeenCalledWith ( { type : 'error' , message : 'app.newApp.appCreateFailed' } )
} )
} )
it ( 'should call exportAppConfig API when exporting' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.export' ) )
} )
await waitFor ( ( ) = > {
expect ( appsService . exportAppConfig ) . toHaveBeenCalled ( )
} )
} )
it ( 'should handle export failure' , async ( ) = > {
2025-12-22 08:35:22 +00:00
( appsService . exportAppConfig as Mock ) . mockRejectedValueOnce ( new Error ( 'Export failed' ) )
2025-12-16 02:42:34 +00:00
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.export' ) )
} )
await waitFor ( ( ) = > {
expect ( appsService . exportAppConfig ) . toHaveBeenCalled ( )
expect ( mockNotify ) . toHaveBeenCalledWith ( { type : 'error' , message : 'app.exportFailed' } )
} )
} )
} )
describe ( 'Switch Modal' , ( ) = > {
it ( 'should open switch modal when switch button is clicked' , async ( ) = > {
const chatApp = { . . . mockApp , mode : AppModeEnum.CHAT }
render ( < AppCard app = { chatApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.switch' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'switch-modal' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should close switch modal when close button is clicked' , async ( ) = > {
const chatApp = { . . . mockApp , mode : AppModeEnum.CHAT }
render ( < AppCard app = { chatApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.switch' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'switch-modal' ) ) . toBeInTheDocument ( )
} )
fireEvent . click ( screen . getByTestId ( 'close-switch-modal' ) )
await waitFor ( ( ) = > {
expect ( screen . queryByTestId ( 'switch-modal' ) ) . not . toBeInTheDocument ( )
} )
} )
it ( 'should call onRefresh after successful switch' , async ( ) = > {
const chatApp = { . . . mockApp , mode : AppModeEnum.CHAT }
render ( < AppCard app = { chatApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.switch' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'switch-modal' ) ) . toBeInTheDocument ( )
} )
fireEvent . click ( screen . getByTestId ( 'confirm-switch-modal' ) )
await waitFor ( ( ) = > {
expect ( mockOnRefresh ) . toHaveBeenCalled ( )
} )
} )
it ( 'should open switch modal for completion mode apps' , async ( ) = > {
const completionApp = { . . . mockApp , mode : AppModeEnum.COMPLETION }
render ( < AppCard app = { completionApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.switch' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'switch-modal' ) ) . toBeInTheDocument ( )
} )
} )
} )
describe ( 'Open in Explore' , ( ) = > {
it ( 'should show open in explore option when popover is opened' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.openInExplore' ) ) . toBeInTheDocument ( )
} )
} )
} )
describe ( 'Workflow Export with Environment Variables' , ( ) = > {
it ( 'should check for secret environment variables in workflow apps' , async ( ) = > {
const workflowApp = { . . . mockApp , mode : AppModeEnum.WORKFLOW }
render ( < AppCard app = { workflowApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.export' ) )
} )
await waitFor ( ( ) = > {
expect ( workflowService . fetchWorkflowDraft ) . toHaveBeenCalled ( )
} )
} )
it ( 'should show DSL export modal when workflow has secret variables' , async ( ) = > {
2025-12-22 08:35:22 +00:00
( workflowService . fetchWorkflowDraft as Mock ) . mockResolvedValueOnce ( {
2025-12-16 02:42:34 +00:00
environment_variables : [ { value_type : 'secret' , name : 'API_KEY' } ] ,
} )
const workflowApp = { . . . mockApp , mode : AppModeEnum.WORKFLOW }
render ( < AppCard app = { workflowApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.export' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'dsl-export-modal' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should check for secret environment variables in advanced chat apps' , async ( ) = > {
const advancedChatApp = { . . . mockApp , mode : AppModeEnum.ADVANCED_CHAT }
render ( < AppCard app = { advancedChatApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.export' ) )
} )
await waitFor ( ( ) = > {
expect ( workflowService . fetchWorkflowDraft ) . toHaveBeenCalled ( )
} )
} )
2025-12-19 07:21:21 +00:00
it ( 'should close DSL export modal when onClose is called' , async ( ) = > {
2025-12-22 08:35:22 +00:00
( workflowService . fetchWorkflowDraft as Mock ) . mockResolvedValueOnce ( {
2025-12-19 07:21:21 +00:00
environment_variables : [ { value_type : 'secret' , name : 'API_KEY' } ] ,
} )
const workflowApp = { . . . mockApp , mode : AppModeEnum.WORKFLOW }
render ( < AppCard app = { workflowApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.export' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'dsl-export-modal' ) ) . toBeInTheDocument ( )
} )
// Click close button to trigger onClose
fireEvent . click ( screen . getByTestId ( 'close-dsl-export' ) )
await waitFor ( ( ) = > {
expect ( screen . queryByTestId ( 'dsl-export-modal' ) ) . not . toBeInTheDocument ( )
} )
} )
2025-12-16 02:42:34 +00:00
} )
describe ( 'Edge Cases' , ( ) = > {
it ( 'should handle empty description' , ( ) = > {
const appNoDesc = { . . . mockApp , description : '' }
render ( < AppCard app = { appNoDesc } / > )
expect ( screen . getByText ( 'Test App' ) ) . toBeInTheDocument ( )
} )
it ( 'should handle long app name' , ( ) = > {
const longNameApp = {
. . . mockApp ,
name : 'This is a very long app name that might overflow the container' ,
}
render ( < AppCard app = { longNameApp } / > )
expect ( screen . getByText ( longNameApp . name ) ) . toBeInTheDocument ( )
} )
it ( 'should handle empty tags array' , ( ) = > {
const noTagsApp = { . . . mockApp , tags : [ ] }
// With empty tags, the component should still render successfully
render ( < AppCard app = { noTagsApp } / > )
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
} )
it ( 'should handle missing author name' , ( ) = > {
const noAuthorApp = { . . . mockApp , author_name : '' }
render ( < AppCard app = { noAuthorApp } / > )
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
} )
it ( 'should handle null icon_url' , ( ) = > {
const nullIconApp = { . . . mockApp , icon_url : null }
// With null icon_url, the component should fall back to emoji icon and render successfully
render ( < AppCard app = { nullIconApp } / > )
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
} )
it ( 'should use created_at when updated_at is not available' , ( ) = > {
const noUpdateApp = { . . . mockApp , updated_at : 0 }
render ( < AppCard app = { noUpdateApp } / > )
expect ( screen . getByText ( /edited/i ) ) . toBeInTheDocument ( )
} )
it ( 'should handle agent chat mode apps' , ( ) = > {
const agentApp = { . . . mockApp , mode : AppModeEnum.AGENT_CHAT }
render ( < AppCard app = { agentApp } / > )
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
} )
it ( 'should handle advanced chat mode apps' , ( ) = > {
const advancedApp = { . . . mockApp , mode : AppModeEnum.ADVANCED_CHAT }
render ( < AppCard app = { advancedApp } / > )
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
} )
it ( 'should handle apps with multiple tags' , ( ) = > {
const multiTagApp = {
. . . mockApp ,
tags : [
{ id : 'tag1' , name : 'Tag 1' , type : 'app' , binding_count : 0 } ,
{ id : 'tag2' , name : 'Tag 2' , type : 'app' , binding_count : 0 } ,
{ id : 'tag3' , name : 'Tag 3' , type : 'app' , binding_count : 0 } ,
] ,
}
render ( < AppCard app = { multiTagApp } / > )
// Verify the tag selector renders (actual tag display is handled by the real TagSelector component)
expect ( screen . getByLabelText ( 'tag-selector' ) ) . toBeInTheDocument ( )
} )
it ( 'should handle edit failure' , async ( ) = > {
2025-12-22 08:35:22 +00:00
( appsService . updateAppInfo as Mock ) . mockRejectedValueOnce ( new Error ( 'Edit failed' ) )
2025-12-16 02:42:34 +00:00
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.editApp' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'edit-app-modal' ) ) . toBeInTheDocument ( )
} )
fireEvent . click ( screen . getByTestId ( 'confirm-edit-modal' ) )
await waitFor ( ( ) = > {
expect ( appsService . updateAppInfo ) . toHaveBeenCalled ( )
expect ( mockNotify ) . toHaveBeenCalledWith ( { type : 'error' , message : expect.stringContaining ( 'Edit failed' ) } )
} )
} )
it ( 'should close edit modal after successful edit' , async ( ) = > {
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.editApp' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'edit-app-modal' ) ) . toBeInTheDocument ( )
} )
fireEvent . click ( screen . getByTestId ( 'confirm-edit-modal' ) )
await waitFor ( ( ) = > {
expect ( mockOnRefresh ) . toHaveBeenCalled ( )
} )
} )
it ( 'should render all app modes correctly' , ( ) = > {
const modes = [
AppModeEnum . CHAT ,
AppModeEnum . COMPLETION ,
AppModeEnum . WORKFLOW ,
AppModeEnum . ADVANCED_CHAT ,
AppModeEnum . AGENT_CHAT ,
]
modes . forEach ( ( mode ) = > {
const testApp = { . . . mockApp , mode }
const { unmount } = render ( < AppCard app = { testApp } / > )
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
unmount ( )
} )
} )
it ( 'should handle workflow draft fetch failure during export' , async ( ) = > {
2025-12-22 08:35:22 +00:00
( workflowService . fetchWorkflowDraft as Mock ) . mockRejectedValueOnce ( new Error ( 'Fetch failed' ) )
2025-12-16 02:42:34 +00:00
const workflowApp = { . . . mockApp , mode : AppModeEnum.WORKFLOW }
render ( < AppCard app = { workflowApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.export' ) )
} )
await waitFor ( ( ) = > {
expect ( workflowService . fetchWorkflowDraft ) . toHaveBeenCalled ( )
expect ( mockNotify ) . toHaveBeenCalledWith ( { type : 'error' , message : 'app.exportFailed' } )
} )
} )
} )
// --------------------------------------------------------------------------
// Additional Edge Cases for Coverage
// --------------------------------------------------------------------------
describe ( 'Additional Coverage' , ( ) = > {
it ( 'should handle onRefresh callback in switch modal success' , async ( ) = > {
const chatApp = createMockApp ( { mode : AppModeEnum.CHAT } )
render ( < AppCard app = { chatApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.switch' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'switch-modal' ) ) . toBeInTheDocument ( )
} )
// Trigger success callback
fireEvent . click ( screen . getByTestId ( 'confirm-switch-modal' ) )
await waitFor ( ( ) = > {
expect ( mockOnRefresh ) . toHaveBeenCalled ( )
} )
} )
it ( 'should render popover menu with correct styling for different app modes' , async ( ) = > {
// Test completion mode styling
const completionApp = createMockApp ( { mode : AppModeEnum.COMPLETION } )
const { unmount } = render ( < AppCard app = { completionApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.editApp' ) ) . toBeInTheDocument ( )
} )
unmount ( )
// Test workflow mode styling
const workflowApp = createMockApp ( { mode : AppModeEnum.WORKFLOW } )
render ( < AppCard app = { workflowApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.editApp' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should stop propagation when clicking tag selector area' , ( ) = > {
const multiTagApp = createMockApp ( {
tags : [ { id : 'tag1' , name : 'Tag 1' , type : 'app' , binding_count : 0 } ] ,
} )
render ( < AppCard app = { multiTagApp } / > )
const tagSelector = screen . getByLabelText ( 'tag-selector' )
expect ( tagSelector ) . toBeInTheDocument ( )
2025-12-19 07:21:21 +00:00
// Click on tag selector wrapper to trigger stopPropagation
const tagSelectorWrapper = tagSelector . closest ( 'div' )
if ( tagSelectorWrapper )
fireEvent . click ( tagSelectorWrapper )
} )
it ( 'should handle popover mouse leave' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
// Open popover
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'popover-content' ) ) . toBeInTheDocument ( )
} )
// Trigger mouse leave on the outer popover-content
fireEvent . mouseLeave ( screen . getByTestId ( 'popover-content' ) )
await waitFor ( ( ) = > {
expect ( screen . queryByTestId ( 'popover-content' ) ) . not . toBeInTheDocument ( )
} )
} )
it ( 'should handle operations menu mouse leave' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
// Open popover
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.editApp' ) ) . toBeInTheDocument ( )
} )
// Find the Operations wrapper div (contains the menu items)
const editButton = screen . getByText ( 'app.editApp' )
const operationsWrapper = editButton . closest ( 'div.relative' )
// Trigger mouse leave on the Operations wrapper to call onMouseLeave
if ( operationsWrapper )
fireEvent . mouseLeave ( operationsWrapper )
} )
it ( 'should click open in explore button' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
const openInExploreBtn = screen . getByText ( 'app.openInExplore' )
fireEvent . click ( openInExploreBtn )
} )
// Verify openAsyncWindow was called with callback and options
await waitFor ( ( ) = > {
expect ( mockOpenAsyncWindow ) . toHaveBeenCalledWith (
expect . any ( Function ) ,
expect . objectContaining ( { onError : expect.any ( Function ) } ) ,
)
} )
} )
it ( 'should handle open in explore via async window' , async ( ) = > {
// Configure mockOpenAsyncWindow to actually call the callback
mockOpenAsyncWindow . mockImplementationOnce ( async ( callback : ( ) = > Promise < string > ) = > {
await callback ( )
} )
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
const openInExploreBtn = screen . getByText ( 'app.openInExplore' )
fireEvent . click ( openInExploreBtn )
} )
await waitFor ( ( ) = > {
2025-12-22 08:35:22 +00:00
expect ( exploreService . fetchInstalledAppList ) . toHaveBeenCalledWith ( mockApp . id )
2025-12-19 07:21:21 +00:00
} )
} )
it ( 'should handle open in explore API failure' , async ( ) = > {
2025-12-22 08:35:22 +00:00
( exploreService . fetchInstalledAppList as Mock ) . mockRejectedValueOnce ( new Error ( 'API Error' ) )
2025-12-19 07:21:21 +00:00
// Configure mockOpenAsyncWindow to call the callback and trigger error
2026-02-13 05:21:09 +00:00
mockOpenAsyncWindow . mockImplementationOnce ( async ( callback : ( ) = > Promise < string > , options ? : { onError ? : ( err : unknown ) = > void } ) = > {
2025-12-19 07:21:21 +00:00
try {
await callback ( )
}
catch ( err ) {
options ? . onError ? . ( err )
}
} )
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
const openInExploreBtn = screen . getByText ( 'app.openInExplore' )
fireEvent . click ( openInExploreBtn )
} )
await waitFor ( ( ) = > {
2025-12-22 08:35:22 +00:00
expect ( exploreService . fetchInstalledAppList ) . toHaveBeenCalled ( )
2025-12-19 07:21:21 +00:00
} )
} )
} )
describe ( 'Access Control' , ( ) = > {
it ( 'should render operations menu correctly' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.editApp' ) ) . toBeInTheDocument ( )
expect ( screen . getByText ( 'app.duplicate' ) ) . toBeInTheDocument ( )
expect ( screen . getByText ( 'app.export' ) ) . toBeInTheDocument ( )
expect ( screen . getByText ( 'common.operation.delete' ) ) . toBeInTheDocument ( )
} )
} )
} )
describe ( 'Open in Explore - No App Found' , ( ) = > {
it ( 'should handle case when installed_apps is empty array' , async ( ) = > {
2025-12-22 08:35:22 +00:00
( exploreService . fetchInstalledAppList as Mock ) . mockResolvedValueOnce ( { installed_apps : [ ] } )
2025-12-19 07:21:21 +00:00
// Configure mockOpenAsyncWindow to call the callback and trigger error
2026-02-13 05:21:09 +00:00
mockOpenAsyncWindow . mockImplementationOnce ( async ( callback : ( ) = > Promise < string > , options ? : { onError ? : ( err : unknown ) = > void } ) = > {
2025-12-19 07:21:21 +00:00
try {
await callback ( )
}
catch ( err ) {
options ? . onError ? . ( err )
}
} )
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
const openInExploreBtn = screen . getByText ( 'app.openInExplore' )
fireEvent . click ( openInExploreBtn )
} )
await waitFor ( ( ) = > {
2025-12-22 08:35:22 +00:00
expect ( exploreService . fetchInstalledAppList ) . toHaveBeenCalled ( )
2025-12-19 07:21:21 +00:00
} )
} )
it ( 'should handle case when API throws in callback' , async ( ) = > {
2025-12-22 08:35:22 +00:00
( exploreService . fetchInstalledAppList as Mock ) . mockRejectedValueOnce ( new Error ( 'Network error' ) )
2025-12-19 07:21:21 +00:00
// Configure mockOpenAsyncWindow to call the callback without catching
mockOpenAsyncWindow . mockImplementationOnce ( async ( callback : ( ) = > Promise < string > ) = > {
return await callback ( )
} )
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
const openInExploreBtn = screen . getByText ( 'app.openInExplore' )
fireEvent . click ( openInExploreBtn )
} )
await waitFor ( ( ) = > {
2025-12-22 08:35:22 +00:00
expect ( exploreService . fetchInstalledAppList ) . toHaveBeenCalled ( )
2025-12-19 07:21:21 +00:00
} )
} )
} )
describe ( 'Draft Trigger Apps' , ( ) = > {
it ( 'should not show open in explore option for apps with has_draft_trigger' , async ( ) = > {
const draftTriggerApp = createMockApp ( { has_draft_trigger : true } )
render ( < AppCard app = { draftTriggerApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.editApp' ) ) . toBeInTheDocument ( )
// openInExplore should not be shown for draft trigger apps
expect ( screen . queryByText ( 'app.openInExplore' ) ) . not . toBeInTheDocument ( )
} )
} )
} )
describe ( 'Non-editor User' , ( ) = > {
it ( 'should handle non-editor workspace users' , ( ) = > {
// This tests the isCurrentWorkspaceEditor=true branch (default mock)
render ( < AppCard app = { mockApp } / > )
expect ( screen . getByTitle ( 'Test App' ) ) . toBeInTheDocument ( )
} )
} )
describe ( 'WebApp Auth Enabled' , ( ) = > {
beforeEach ( ( ) = > {
mockWebappAuthEnabled = true
} )
it ( 'should show access control option when webapp_auth is enabled' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.accessControl' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should click access control button' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
const accessControlBtn = screen . getByText ( 'app.accessControl' )
fireEvent . click ( accessControlBtn )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'access-control-modal' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should close access control modal and call onRefresh' , async ( ) = > {
render ( < AppCard app = { mockApp } onRefresh = { mockOnRefresh } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.accessControl' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'access-control-modal' ) ) . toBeInTheDocument ( )
} )
// Confirm access control
fireEvent . click ( screen . getByTestId ( 'confirm-access-control' ) )
await waitFor ( ( ) = > {
expect ( mockOnRefresh ) . toHaveBeenCalled ( )
} )
} )
it ( 'should show open in explore when userCanAccessApp is true' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
expect ( screen . getByText ( 'app.openInExplore' ) ) . toBeInTheDocument ( )
} )
} )
it ( 'should close access control modal when onClose is called' , async ( ) = > {
render ( < AppCard app = { mockApp } / > )
fireEvent . click ( screen . getByTestId ( 'popover-trigger' ) )
await waitFor ( ( ) = > {
fireEvent . click ( screen . getByText ( 'app.accessControl' ) )
} )
await waitFor ( ( ) = > {
expect ( screen . getByTestId ( 'access-control-modal' ) ) . toBeInTheDocument ( )
} )
// Click close button to trigger onClose
fireEvent . click ( screen . getByTestId ( 'close-access-control' ) )
await waitFor ( ( ) = > {
expect ( screen . queryByTestId ( 'access-control-modal' ) ) . not . toBeInTheDocument ( )
} )
2025-12-16 02:42:34 +00:00
} )
} )
} )